1535 lines
84 KiB
TypeScript
1535 lines
84 KiB
TypeScript
// SPEC: PJ2026-01010305 71FREQ preinstall draft-2026-06-26-71freq-v03-hwpod-preinstall.
|
||
// Responsibility: v0.3 CaseRun runtime-origin resolution, evidence normalization, and compile-only HWPOD smoke orchestration; SPEC: PJ2026-0103 HarnessRL draft-2026-06-25-p0-web-caserun-e2e.
|
||
|
||
import { spawn } from "node:child_process";
|
||
import { 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 { collectAgentWorkspaceDiff } from "./hwlab-caserun-diff.ts";
|
||
import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseRegistryRun } from "./hwlab-caserun-diagnostics.ts";
|
||
import { agentTerminalEvidence, agentToolCallSummaryFromResult, agentWithResultEvidence, applyCaseRunEvidenceRelationships, clean, cliError, clipText, commandVisibility, commandsFromToolCallSummary, compactObject, firstNumberOption, firstTextOption, numberOption, parseJsonMaybe, sha256, summarizeAgentTrace, text, type AgentToolCallSummary } from "./hwlab-caserun-evidence.ts";
|
||
import { apiUrlFrom, webUrlFrom } from "./hwlab-caserun-runtime-config.ts";
|
||
import { readHwpodSpec } from "./hwpod-harness-lib.ts";
|
||
import { harnessRLRuntime } from "../../internal/harnessrl/runtime.ts";
|
||
import { createApiCaseRunTransport, createLocalCaseRunTransport, type CaseRunCommand, type CaseRunTransport } from "../../internal/harnessrl/transport.ts";
|
||
import { caseValidationPlanFromDefinition, hwpodCommandForValidationStep, normalizeValidationStepResult, validationReplayRelationship } from "./hwlab-caserun-validation.ts";
|
||
import { prepareSubjectWorktree, caseRunForSubjectSnapshot, subjectFromDefinition, requestSubjectWorktree, subjectWorktreeStatus, keilSidecarCopyOp, subjectRelativeKeilProject, stripUvprojx, windowsJoin, keilSidecarCopyNodeScript, keilSidecarSummary, objectRecord, isAbsolutePathLike, subjectRelativeWorktreePath, normalizeLocalPath, subjectWorktreePath, looksLikeWindowsPath, rewriteHwpodSpecWorkspacePath, pollKeilJobStatus, requestKeilJobStatus, keilJobStatus, hwpodResultTexts, artifactsFrom, slug } from "./hwlab-caserun-subject.ts";
|
||
import { agentTraceIdentityArtifact, agentTraceLookup, agentTraceLookupForRun, renderEvidenceSummary, agentStageSummary, agentStageCommand, agentCommandDetail, markdownTableText, writeCaseAggregateFromRegistry, resolveAggregateRunId, aggregateOutputRel, renderCaseAggregateMarkdown, aggregateTraceBody, aggregateMessagesWithFreshFullTrace, freshRenderedTraceBody, traceEventRowFromAggregate, aggregateBullets, aggregateInlineValue, markdownInline, aggregateDetails, escapeHtml, aggregateFence, aggregateArtifactIndex, registryAggregateFileRecord, archiveCaseRunArtifacts, syncCaseRegistryRun, syncCaseRegistryRel, gitProcess, collectSummaryFromArchive, writeReadableAgentArtifacts, archivedAgentTraceSnapshot, existingAgentTraceSummary, archivedAgentTraceBody, materializeFullAgentTraceWithExistingCli, traceRowsFromBody, traceEventRowFromObject, traceRowTone, applyArchivedAgentTraceSummary, agentTraceBody, agentFinalResponse, finalResponseMissingReason, agentMessageRowForArchive, renderAgentTranscript, renderTranscriptRow, renderFinalResponseArtifact, refreshCompletedRunArchive, caseRunArtifactEntries, copyCaseRunArtifact, registryArtifactFileRecord, caseRunArtifactManifest, aggregateManifestEntry, caseRunArtifactTrace, caseRegistryRunRel, artifactRel, artifactSourceLabel } from "./hwlab-caserun-closeout.ts";
|
||
|
||
const 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";
|
||
|
||
import type { ArchivedAgentTraceSnapshot, AgentDiffStage, AgentRunStage, AgentTraceCommand, AgentTraceStage, CaseContext, CaseReadableAgentArchive, CaseRegistryArchive, CaseRegistryArchiveFile, PreparedAgentTask, PreparedCaseRun, SourceRootSnapshot } from "./hwlab-caserun-shared.ts";
|
||
|
||
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 === "prompt") return promptCaseRun(context);
|
||
if (subcommand === "aggregate") return aggregateCaseRun(context);
|
||
if (subcommand === "refresh" || subcommand === "backfill") return refreshCaseRegistryArtifacts(context, await resolveCaseRepo(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 (["start", "status", "events", "aggregate", "cancel"].includes(mode)) return harnessRLCaseRunCommand(context, mode as CaseRunCommand["kind"]);
|
||
if (mode === "result") return resultCaseRun(context);
|
||
if (mode === "logs") return logsCaseRun(context);
|
||
if (mode === "inspect") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "inspect");
|
||
if (mode === "trace-summary" || mode === "trace") return inspectCaseRegistryRun(context, await resolveCaseRepo(context), "trace-summary");
|
||
if (mode === "skill-usage" || mode === "skills") return skillUsageCaseRegistryRun(context, await resolveCaseRepo(context));
|
||
if (mode === "worker") return runCaseRunWorker(context);
|
||
return runCaseRun(context);
|
||
}
|
||
|
||
export function caseHelp() {
|
||
return ok("case.help", {
|
||
runTransport: {
|
||
default: "local HarnessRL application service",
|
||
overApi: "explicit --over-api --base-url <origin>",
|
||
fallback: 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 prompt CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||
`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 audit [--json] [--root PATH] [--source-dir PATH] [--max-lines N]",
|
||
`case refresh CASE_ID --run-id RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--source local|runtime]`,
|
||
`case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`,
|
||
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`,
|
||
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url <origin> [--timeout-ms 30000] [--run-id RUN_ID]`,
|
||
"case run status RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
|
||
"case run events RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
|
||
"case run aggregate RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
|
||
"case run cancel RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
|
||
`case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
|
||
`case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
|
||
`case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`
|
||
],
|
||
notes: [
|
||
"case prompt 是无服务预览入口,只按真实 CaseRun 组装路径渲染输入 prompt,不准备 worktree、不提交 agent、不访问硬件。",
|
||
"case build executes the validation plan declared by the builtin v0.3 case definition.",
|
||
"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 registry-only and offline by default; it reads existing artifacts without querying runtime.",
|
||
"case refresh/backfill defaults to local registry artifacts; only explicit --source runtime may query the existing trace CLI.",
|
||
"case run inspect/trace-summary/skill-usage are bounded, service-free registry diagnostics.",
|
||
"case run start/status/events/aggregate/cancel share one HarnessRL command/DTO/error contract; start returns immediately and the remaining commands progressively disclose durable state.",
|
||
"The default local adapter calls the native HarnessRL application service directly; only explicit --over-api calls HTTP, and neither transport falls back to the other.",
|
||
"This version records raw stage evidence only; it does not auto-grade agent output, diff contents, or Keil evidence."
|
||
]
|
||
});
|
||
}
|
||
|
||
async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunCommand["kind"]) {
|
||
const command: CaseRunCommand = kind === "start"
|
||
? { kind, caseId: requiredText(context.parsed.caseId ?? context.rest[2], "caseId"), ...(text(context.parsed.runId) ? { runId: text(context.parsed.runId) } : {}) }
|
||
: { kind, runId: requiredText(context.parsed.runId ?? context.rest[2], "runId") };
|
||
const transport = await (context.caseRunTransportFactory?.(context) ?? createHarnessRLTransport(context));
|
||
try {
|
||
const response = await transport.execute(command);
|
||
return ok(`case.run.${kind}`, {
|
||
transport: transport.mode,
|
||
command: kind,
|
||
...transport.visibility(command),
|
||
...response,
|
||
...(kind === "start" ? { startReturned: true } : {})
|
||
}, kind === "start" ? "submitted" : "succeeded");
|
||
} finally {
|
||
await transport.close();
|
||
}
|
||
}
|
||
|
||
function createHarnessRLTransport(context: CaseContext): CaseRunTransport {
|
||
if (context.parsed.overApi === true) return createApiCaseRunTransport({ baseUrl: text(context.parsed.baseUrl), fetchImpl: context.fetchImpl, apiKey: text(context.env.HWLAB_API_KEY) || undefined, timeoutMs: firstNumberOption(context.parsed.timeoutMs) });
|
||
if (context.parsed.baseUrl) throw cliError("caserun_over_api_required", "--base-url is only valid with explicit --over-api");
|
||
const env = {
|
||
...context.env,
|
||
HWLAB_CASERUN_REPO_ROOT: context.cwd,
|
||
...(text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) ? { HWLAB_CASERUN_CASE_REPO: text(context.parsed.caseRepo ?? context.parsed.caseRepoPath) } : {}),
|
||
...(text(context.parsed.stateDir) ? { HARNESSRL_STATE_DIR: text(context.parsed.stateDir) } : {})
|
||
};
|
||
const runtime = harnessRLRuntime(env);
|
||
return createLocalCaseRunTransport({ service: runtime.service, close: () => runtime.service.close() });
|
||
}
|
||
|
||
export async function promptCaseRun(context: CaseContext) {
|
||
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 subject = subjectFromDefinition(loaded.definition);
|
||
const worktreePath = subjectWorktreePath(subject.repoLocalPath, runId);
|
||
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
|
||
const validationPlan = caseValidationPlanFromDefinition(loaded.definition);
|
||
const now = context.now();
|
||
const run: PreparedCaseRun = {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo,
|
||
caseDir: loaded.caseDir,
|
||
caseFile: loaded.caseFile,
|
||
sourceSpecPath: loaded.specPath,
|
||
specPath: loaded.specPath,
|
||
apiUrl: apiUrlFrom(context),
|
||
compileOnly: validationPlan.compileOnly,
|
||
validationPlan,
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
definition: loaded.definition,
|
||
subject: { ...subject, worktreePath, workspacePath: worktreePath, setup: { previewOnly: true } },
|
||
agentTask
|
||
};
|
||
const prompt = await renderAgentTaskPrompt(run, agentTask);
|
||
return ok("case.prompt", {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo,
|
||
sourceSpecPath: loaded.specPath,
|
||
compileOnly: validationPlan.compileOnly,
|
||
validationPlan,
|
||
serviceRuntime: false,
|
||
previewOnly: true,
|
||
promptSha256: sha256(prompt),
|
||
agentTask: agentTaskSummary(agentTask),
|
||
subject: { repoLocalPath: subject.repoLocalPath, commitId: subject.commitId, worktreePath },
|
||
prompt
|
||
});
|
||
}
|
||
|
||
export async function aggregateCaseRun(context: CaseContext) {
|
||
const caseRepo = await resolveCaseRepo(context);
|
||
const aggregate = await writeCaseAggregateFromRegistry(context, caseRepo, { sync: context.parsed.noCaseRepoGitSync !== 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: (collected as any).run.compileOnly,
|
||
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: apiUrlFrom(context),
|
||
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: CaseContext = { ...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");
|
||
let run = await readRunById(context, runId);
|
||
let control = controlFromRun(run);
|
||
let status = text(control.status) || text(run.status) || "unknown";
|
||
let stage = statusStage(run, control);
|
||
({ run, control, status, stage } = await finalizeBuildCompletedRun(context, run, control, status, stage));
|
||
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");
|
||
let run = await readRunById(context, runId);
|
||
let control = controlFromRun(run);
|
||
let status = text(control.status) || text(run.status) || "unknown";
|
||
let stage = statusStage(run, control);
|
||
({ run, control, status, stage } = await finalizeBuildCompletedRun(context, run, control, status, stage));
|
||
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);
|
||
const subject = await prepareSubjectWorktree(context, { caseId, runId, runDir, apiUrl, definition: loaded.definition, sourceSpecPath: loaded.specPath }, collectSourceRootSnapshot);
|
||
const agentTask = agentTaskFromDefinition(loaded.definition, caseId);
|
||
const validationPlan = caseValidationPlanFromDefinition(loaded.definition);
|
||
const run: PreparedCaseRun = {
|
||
caseId,
|
||
runId,
|
||
runDir,
|
||
caseRepo,
|
||
caseDir: loaded.caseDir,
|
||
caseFile: loaded.caseFile,
|
||
sourceSpecPath: loaded.specPath,
|
||
specPath,
|
||
apiUrl,
|
||
compileOnly: validationPlan.compileOnly,
|
||
validationPlan,
|
||
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: validationPlan.compileOnly,
|
||
validationPlan,
|
||
agentTask: agentTaskSummary(agentTask),
|
||
subject,
|
||
summary: prepareSummary(run),
|
||
run
|
||
});
|
||
}
|
||
|
||
export async function buildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) {
|
||
return buildCaseRunWithAuthorityMode(context, prepared, false);
|
||
}
|
||
|
||
export async function recoverBuildCaseRun(context: CaseContext, prepared?: PreparedCaseRun) {
|
||
return buildCaseRunWithAuthorityMode(context, prepared, true);
|
||
}
|
||
|
||
async function buildCaseRunWithAuthorityMode(context: CaseContext, prepared: PreparedCaseRun | undefined, recoverOnly: boolean) {
|
||
const run = prepared ?? await loadOrPrepareCaseRun(context);
|
||
const validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition);
|
||
const buildStep = validationPlan.steps.find((step) => step.kind === "build")!;
|
||
const command = hwpodCommandForValidationStep(buildStep, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${validationPlan.mode}` });
|
||
const invoked = recoverOnly
|
||
? await recoverHwpodInvocation(context, run)
|
||
: 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 hwpodDocument = await readHwpodSpec(run.specPath);
|
||
const operation = summarizeHwpodOperationForTest(hwpodPayload, hwpodDocument, "debug.build");
|
||
const jobId = operation.jobId;
|
||
const job = jobId ? await pollKeilJobStatus(context, run, jobId, caseTimeoutsFromDefinition(run.definition)) : null;
|
||
const buildWaitBlocker = buildWaitBlockerForTest(operation, job?.summary ?? null, invoked.exitCode);
|
||
const buildValidation = normalizeValidationStepResult({ step: buildStep, payload: hwpodPayload, document: hwpodDocument, exitCode: invoked.exitCode, stderr: invoked.stderr });
|
||
const validationResults = [{ ...buildValidation, blocker: buildWaitBlocker ?? buildValidation.blocker }];
|
||
for (const step of validationPlan.steps.filter((item) => item.kind !== "build")) {
|
||
if (validationResults.some((item) => item.blocker)) break;
|
||
const stepCommand = hwpodCommandForValidationStep(step, { executable: process.execPath, cliPath: path.join(context.cwd, "tools/hwpod-cli.ts"), specPath: run.specPath, reason: `case-run ${run.caseId} ${run.runId} ${step.kind}` });
|
||
const stepInvoked = await (context.runProcess ?? runProcess)(stepCommand, context.cwd, {
|
||
...process.env,
|
||
...context.env,
|
||
HWLAB_RUNTIME_API_URL: run.apiUrl,
|
||
HWLAB_RUNTIME_ENDPOINT_LOCKED: "1"
|
||
});
|
||
validationResults.push(normalizeValidationStepResult({ step, payload: parseJsonMaybe(stepInvoked.stdout), document: hwpodDocument, exitCode: stepInvoked.exitCode, stderr: stepInvoked.stderr }));
|
||
}
|
||
const validationBlocker = validationResults.find((item) => item.blocker)?.blocker ?? null;
|
||
const validationReplay = validationReplayRelationship(validationPlan, validationResults);
|
||
const validationArchive = clean({ contractVersion: "hwpod-case-validation-observations-v1", caseId: run.caseId, runId: run.runId, observedAt: context.now(), plan: validationPlan, results: validationResults, replay: validationReplay });
|
||
await writeJson(path.join(run.runDir, "validation-observations.json"), validationArchive);
|
||
const evidence = clean({
|
||
contractVersion: "hwpod-case-run-evidence-v1",
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
compileOnly: validationPlan.compileOnly,
|
||
autoEvaluation: false,
|
||
status: validationBlocker ? "blocked" : "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),
|
||
operation,
|
||
buildWaitBlocker
|
||
}),
|
||
keilJob: job ? job.summary : null,
|
||
validation: clean({ plan: validationPlan, results: validationResults, blocker: validationBlocker, archivePath: "validation-observations.json" }),
|
||
replay: validationReplay,
|
||
artifacts: job?.summary && "artifacts" in job.summary ? job.summary.artifacts : [],
|
||
decisions: {
|
||
autoEvaluation: false,
|
||
runnerPostAgentCompileCheck: "recorded",
|
||
reason: "CaseRun records Agent final independently and projects only authoritative HWPOD post-validation observations"
|
||
}
|
||
});
|
||
applyCaseRunEvidenceRelationships(evidence, run);
|
||
const evidencePath = path.join(run.runDir, "evidence.json");
|
||
await writeJson(evidencePath, evidence);
|
||
const completed = await writeRunControl(context, { ...run, status: "completed", stage: "completed", evidencePath }, { status: "completed", stage: "completed", completedAt: context.now(), exitCode: invoked.exitCode, evidencePath });
|
||
return ok("case.build", {
|
||
caseId: run.caseId,
|
||
runId: run.runId,
|
||
runDir: run.runDir,
|
||
compileOnly: validationPlan.compileOnly,
|
||
validationPlan,
|
||
summary: buildSummary(evidence),
|
||
evidence,
|
||
run: completed.run
|
||
}, "completed");
|
||
}
|
||
|
||
export function summarizeHwpodOperationForTest(payload: any, document: any, expectedOp = "") {
|
||
const response = objectRecord(payload?.body?.body ?? payload?.body ?? payload);
|
||
const results = Array.isArray(response.results) ? response.results : [];
|
||
const result = results.find((item: any) => !expectedOp || text(item?.op) === expectedOp) ?? results[0] ?? {};
|
||
const output = objectRecord(result?.output);
|
||
const expected = {
|
||
hwpodId: text(document?.metadata?.name ?? document?.metadata?.uid),
|
||
nodeId: text(document?.spec?.nodeBinding?.nodeId),
|
||
workspacePath: text(document?.spec?.workspace?.path)
|
||
};
|
||
const observed = {
|
||
hwpodId: firstTextOption(response.hwpodId, result?.hwpodId, output.hwpodId),
|
||
nodeId: firstTextOption(response.nodeId, result?.nodeId, output.nodeId),
|
||
workspacePath: firstTextOption(result?.workspacePath, output.workspacePath, output.cwd)
|
||
};
|
||
const mismatch = Object.entries(expected).flatMap(([field, value]) => {
|
||
if (!value) return [];
|
||
const observedValue = observed[field as keyof typeof observed];
|
||
return observedValue === value ? [] : [{ field, expected: value, observed: observedValue || null }];
|
||
});
|
||
return clean({
|
||
contractVersion: "hwpod-operation-result-v0.3",
|
||
authority: "case-hwpod-yaml",
|
||
op: text(result?.op) || expectedOp,
|
||
opId: text(result?.opId),
|
||
status: text(result?.status ?? response.status ?? payload?.status) || "unknown",
|
||
ok: result?.ok === true && mismatch.length === 0,
|
||
jobId: extractKeilJobId(payload),
|
||
expected,
|
||
observed: clean(observed),
|
||
topology: mismatch.length === 0 ? { ok: true } : { ok: false, blocker: { code: "hwpod_topology_mismatch", summary: "HWPOD operation result does not match YAML-selected topology", details: mismatch } },
|
||
blocker: result?.blocker ?? payload?.diagnostic ?? null
|
||
});
|
||
}
|
||
|
||
export function buildWaitBlockerForTest(operation: any, job: any, buildExitCode = 0) {
|
||
if (operation?.topology?.ok === false) return operation.topology.blocker;
|
||
if (operation?.blocker) return operation.blocker;
|
||
if (buildExitCode !== 0) return { code: "hwpod_build_operation_failed", summary: "HWPOD build operation failed before a waitable job was returned" };
|
||
if (!text(operation?.jobId)) return { code: "hwpod_build_job_id_missing", summary: "HWPOD build operation result did not return a jobId" };
|
||
if (job?.timedOut === true) return { code: "hwpod_build_wait_timeout", summary: "HWPOD build job did not reach a terminal state within the bounded wait", details: { jobId: operation.jobId, polls: job.polls } };
|
||
if (job && job.ok === false) return { code: "hwpod_build_failed", summary: "HWPOD build job reached a failed terminal state", details: { jobId: operation.jobId, status: job.status, returnCode: job.returnCode } };
|
||
const artifacts = Array.isArray(job?.artifacts) ? job.artifacts.map(String) : [];
|
||
if (job && !artifacts.some((item: string) => /\.hex$/iu.test(item))) return { code: "hwpod_build_hex_missing", summary: "HWPOD build completed without a HEX artifact", details: { jobId: operation.jobId, artifacts } };
|
||
return null;
|
||
}
|
||
|
||
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: run.compileOnly, 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 {
|
||
if (context.parsed.noCaseRepoRecord === true && await statLikeDirectory(path.join(candidate, "cases"))) return 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 });
|
||
}
|
||
|
||
async function statLikeDirectory(candidate: string) {
|
||
try {
|
||
const info = await stat(candidate);
|
||
return info.isDirectory();
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
export 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"
|
||
};
|
||
}
|
||
|
||
export 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);
|
||
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 = "sessionId" in session ? session.sessionId : "";
|
||
const threadId = "threadId" in session ? 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 = agentWithResultEvidence({
|
||
stageStatus: result.stageStatus,
|
||
baseUrl,
|
||
projectId,
|
||
providerProfile,
|
||
conversationId,
|
||
sessionId,
|
||
threadId,
|
||
traceId,
|
||
promptPath,
|
||
promptSha256,
|
||
accepted: compactObject(accepted.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
|
||
}, result.body, run.agentTask) as AgentRunStage;
|
||
const nextRun = await updateRun(context, run, { agent });
|
||
return { run: nextRun, agent };
|
||
}
|
||
|
||
export async function recoverAgentTaskStage(context: CaseContext, run: PreparedCaseRun) {
|
||
const baseUrl = webUrlFrom(context);
|
||
const providerProfile = text(context.parsed.providerProfile) || run.agentTask.providerProfile;
|
||
const projectId = text(context.parsed.projectId) || run.agentTask.projectId;
|
||
const conversationId = text(context.parsed.conversationId) || `cnv_case_${slug(run.caseId)}_${slug(run.runId)}`;
|
||
const traceId = text(context.parsed.traceId);
|
||
if (!traceId) throw Object.assign(new Error("AgentRun recovery requires the stable trace identity"), { code: "agentrun_recovery_identity_required" });
|
||
const existing = run.agent?.traceId === traceId ? run.agent : null;
|
||
const resultUrl = text(existing?.resultUrl) || `${baseUrl}/v1/agent/chat/result/${encodeURIComponent(traceId)}`;
|
||
const recoveredAgent = existing ?? agentFailureStage({
|
||
stageStatus: "recovering",
|
||
baseUrl,
|
||
projectId,
|
||
providerProfile,
|
||
conversationId,
|
||
traceId,
|
||
promptPath: path.join(run.runDir, "agent-prompt.md"),
|
||
promptSha256: ""
|
||
});
|
||
const result = await pollAgentResult(context, { baseUrl, traceId, acceptedBody: { resultUrl }, resultUrl, run, agent: recoveredAgent, notFoundCode: "agentrun_task_not_found" });
|
||
const agent = agentWithResultEvidence({
|
||
...recoveredAgent,
|
||
stageStatus: result.stageStatus,
|
||
resultUrl,
|
||
polls: result.polls,
|
||
timedOut: result.timedOut,
|
||
lastPollAt: result.lastPollAt,
|
||
lastHttpStatus: result.lastHttpStatus,
|
||
error: result.error
|
||
}, result.body, run.agentTask) as AgentRunStage;
|
||
const nextRun = await updateRun(context, run, { stage: "agent-recovered", agent });
|
||
return { run: nextRun, agent };
|
||
}
|
||
|
||
export 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");
|
||
}
|
||
|
||
async function pollAgentResult(context: CaseContext, input: { baseUrl: string; traceId: string; acceptedBody: any; resultUrl?: string; run?: PreparedCaseRun; agent?: AgentRunStage; notFoundCode?: string }) {
|
||
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 (!response.ok && response.status === 404 && input.notFoundCode) {
|
||
throw Object.assign(new Error(`AgentRun task ${input.traceId} was not found`), { code: input.notFoundCode, traceId: input.traceId, status: response.status });
|
||
}
|
||
if (!response.ok) return { stageStatus: "result_request_failed", body: lastBody, polls, timedOut: false, lastPollAt, lastHttpStatus: lastStatus, error: response.error };
|
||
if (input.run && input.agent) {
|
||
const runningAgent = agentWithResultEvidence({ ...input.agent, stageStatus: "running", polls, timeoutMs, pollIntervalMs, resultUrl, lastPollAt, lastHttpStatus: lastStatus }, lastBody, input.run.agentTask);
|
||
await updateRun(context, input.run, { stage: "agent-running", agent: runningAgent });
|
||
}
|
||
const terminal = agentTerminalEvidence(lastBody);
|
||
const status = text(lastBody?.status);
|
||
if ((status && status !== "running") || terminal.terminal) {
|
||
return { stageStatus: status && status !== "running" ? status : terminal.terminalStatus || "completed", 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) } };
|
||
}
|
||
|
||
async function recoverHwpodInvocation(context: CaseContext, run: PreparedCaseRun) {
|
||
const planId = text(context.env.HWLAB_HWPOD_OPERATION_IDENTITY);
|
||
if (!planId) throw Object.assign(new Error("HWPOD recovery requires the stable operation identity"), { code: "hwpod_recovery_identity_required" });
|
||
const timeoutMs = Math.max(numberOption(context.parsed.timeoutMs) ?? DEFAULT_KEIL_COMMAND_TIMEOUT_MS, 1000);
|
||
const startedAt = Date.now();
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
const response = await context.fetchImpl(`${run.apiUrl}/v1/hwpod-node-ops?planId=${encodeURIComponent(planId)}`, { method: "GET" });
|
||
const body = parseJsonMaybe(await response.text());
|
||
if (response.status === 404) throw Object.assign(new Error(`HWPOD operation ${planId} was not found`), { code: "hwpod_operation_not_found", planId });
|
||
if (!response.ok) throw Object.assign(new Error(`HWPOD operation lookup failed with HTTP ${response.status}`), { code: "hwpod_operation_lookup_failed", planId, status: response.status });
|
||
if (body?.result) return { stdout: JSON.stringify(body.result), stderr: "", exitCode: body.result.ok === false ? 1 : 0 };
|
||
await context.sleep(Math.max(numberOption(context.parsed.pollIntervalMs) ?? DEFAULT_POLL_INTERVAL_MS, 250));
|
||
}
|
||
throw Object.assign(new Error(`HWPOD operation ${planId} did not complete within ${timeoutMs}ms`), { code: "hwpod_operation_recovery_timeout", planId, timeoutMs });
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
export 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 collected = await collectAgentWorkspaceDiff({
|
||
definition: run.definition,
|
||
subjectSubdir: run.subject.subdir,
|
||
worktreePath: run.subject.worktreePath,
|
||
statusShort: text(status.stdout),
|
||
trackedDiffStat: text(diffStat.stdout),
|
||
trackedPatchText: text(diffPatch.stdout),
|
||
requestGit: (args) => requestSubjectCommand(context, run, ["-C", run.subject.worktreePath, ...args]),
|
||
requestProcess: (command, args) => requestSubjectProcess(context, run, command, args)
|
||
});
|
||
const sourceAfterAgent = await collectSourceRootSnapshot(context, run, "after-agent");
|
||
const statusShort = text(status.stdout);
|
||
const patchText = collected.patchText;
|
||
const diffPatchPath = path.join(run.runDir, "agent-diff.patch");
|
||
await writeFile(diffPatchPath, patchText, "utf8");
|
||
const diff: AgentDiffStage = {
|
||
statusShort,
|
||
diffStat: collected.diffStat,
|
||
diffPatchPath,
|
||
diffPatchSha256: sha256(patchText),
|
||
diffCollection: collected.collection,
|
||
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),
|
||
...collected.requests.map((request) => requestSummary(request.label, request.result)),
|
||
{ 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 };
|
||
}
|
||
|
||
export async function requestSubjectProcess(context: CaseContext, run: PreparedCaseRun, command: string, 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: stableOperationIdentity(context, `subject-${command}-${argv.join("-")}`),
|
||
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, 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), command: [command, ...argv] };
|
||
}
|
||
|
||
export 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: stableOperationIdentity(context, `subject-git-${argv.join("-")}`),
|
||
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) };
|
||
}
|
||
|
||
function stableOperationIdentity(context: CaseContext, suffix: string) {
|
||
const base = text(context.parsed.operationIdentity);
|
||
return base ? `${base}:${slug(suffix)}` : `case_subject_cmd_${randomUUID()}`;
|
||
}
|
||
|
||
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 validationPlan = run.validationPlan ?? caseValidationPlanFromDefinition(run.definition);
|
||
const validationModeLine = validationPlan.compileOnly
|
||
? "验证模式: 仅执行编译构建检查;不下载、不做运行态冒烟验证。"
|
||
: `验证模式: ${validationPlan.mode};Agent final 与 CaseRun 后置 HWPOD validation 独立记录。`;
|
||
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 代码代理任务`,
|
||
``,
|
||
`案例ID: ${run.caseId}`,
|
||
`运行ID: ${run.runId}`,
|
||
`主体仓库本地路径: ${run.subject.repoLocalPath}`,
|
||
`主体提交ID: ${run.subject.commitId}`,
|
||
`主体隔离工作区路径: ${run.subject.worktreePath}`,
|
||
`hwpodId: ${hwpodId}`,
|
||
`hwpodWorkspaceArgs: ${hwpodWorkspaceArgs}`,
|
||
validationModeLine,
|
||
`CaseRun 后置验证动作: ${validationPlan.steps.map((step) => `${step.actionId}:${step.kind}`).join(", ")}`,
|
||
``,
|
||
`## 运行时装配`,
|
||
`AgentRun 通过 ResourceBundleRef kind=gitbundle 装配 HWLAB 运行时资源:仓库子路径 \`tools\` 会复制到工作区 \`tools\`,仓库子路径 \`skills\` 会复制到工作区 \`.agents/skills\`。CaseRun 不再发送临时 workspaceFiles 或 seed-file 载荷。`,
|
||
``,
|
||
`## HWPOD 运行时`,
|
||
`通过运行时服务按 ID 使用 HWPOD,不要求 runner 本地存在 \`.hwlab/hwpod-spec.yaml\`。`,
|
||
`每个 hwpod/hwpod-ctl 命令都必须携带这些参数:\`${hwpodWorkspaceArgs}\`。`,
|
||
`本任务的标准冒烟步骤:`,
|
||
`- \`hwpod-ctl spec validate ${hwpodWorkspaceArgs}\``,
|
||
`- \`hwpod inspect ${hwpodWorkspaceArgs}\``,
|
||
`- 工作区读取、搜索和编辑:\`hwpod workspace ... ${hwpodWorkspaceArgs}\``,
|
||
`- 编译检查:\`hwpod build ${hwpodWorkspaceArgs}\``,
|
||
``,
|
||
`## 任务`,
|
||
agentTask.prompt,
|
||
``,
|
||
`## 约束`,
|
||
...constraints.map((item) => `- ${item}`),
|
||
``,
|
||
`## 执行流程`,
|
||
`- 先确认标准 \`hwpod\`、\`hwpod-ctl\` 和 \`hwpod-compiler\` 命令可从 gitbundle 工具目录调用。`,
|
||
`- 使用标准 hwpod/hwpod-ctl 命令并携带 \`${hwpodWorkspaceArgs}\` 完成案例任务。只有案例明确要求时才运行 build/download/UART 步骤,并回报返回的 JSON、job、artifact 或串口摘要。`,
|
||
`- 对 hwpod build/download,保持 HWPOD 命令本身不被包装,让它返回异步 JSON;随后用独立短命令 \`hwpod job status <jobId> ${hwpodWorkspaceArgs}\` 对返回的 job id 做有限轮询。不要用 shell sleep、&&、timeout、watch、head、pipe 或 shell loop 包裹状态轮询。`,
|
||
`- 你的回合结束后,CaseRun 会检查 subjectWorktreePath 下的 git diff,并可能把 runner 后置编译作为单独证据记录。`,
|
||
`- CaseRun 只记录 trace、session、conversation、agent 命令执行、workspace diff 和 Keil 构建证据,不做自动评分或门禁判断。`
|
||
].join("\n");
|
||
}
|
||
|
||
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 Cloud Web", { allowed: "HWLAB_API_KEY" });
|
||
return { "content-type": "application/json", authorization: `Bearer ${apiKey}` };
|
||
}
|
||
|
||
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, terminalStatus: agent.terminalStatus, commandStatus: agent.commandStatus, agentRunStatus: agent.agentRunStatus, baseUrl: agent.baseUrl, projectId: agent.projectId, providerProfile: agent.providerProfile, requestedProviderProfile: agent.requestedProviderProfile, resolvedBackendProfile: agent.resolvedBackendProfile, provider: agent.provider, model: agent.model, backend: agent.backend, infrastructureBackend: agent.infrastructureBackend, providerTrace: agent.providerTrace, 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, toolCallSummary: agent.toolCallSummary, 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,
|
||
toolCallSummary: trace.toolCallSummary,
|
||
commands: trace.commands,
|
||
error: trace.error
|
||
});
|
||
}
|
||
|
||
function diffSummary(diff: AgentDiffStage) {
|
||
return clean({
|
||
statusShort: diff.statusShort,
|
||
diffStat: diff.diffStat,
|
||
diffPatchPath: diff.diffPatchPath,
|
||
diffPatchSha256: diff.diffPatchSha256,
|
||
diffCollection: diff.diffCollection,
|
||
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 promptShellArg(value: string) {
|
||
return `'${String(value).replace(/'/gu, `'\\''`)}'`;
|
||
}
|
||
|
||
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), agentFinal: evidence.agentFinal ?? null, postValidation: evidence.postValidation ?? null, warnings: evidence.warnings ?? null, hwpodExitCode: evidence.hwpod?.exitCode ?? null, hwpodSource: evidence.hwpod?.source ?? null, jobId: evidence.keilJob?.jobId ?? null, keilStatus: evidence.keilJob?.status ?? null, artifacts: evidence.artifacts ?? [] };
|
||
}
|
||
|
||
|
||
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 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 });
|
||
}
|
||
|
||
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 finalizeBuildCompletedRun(context: CaseContext, run: PreparedCaseRun, control: Record<string, unknown>, status: string, stage: ReturnType<typeof statusStage>) {
|
||
if (status === "completed" || status === "failed" || stage.stage !== "build-completed") return { run, control, status, stage };
|
||
const evidencePath = text(run.evidencePath ?? control.evidencePath) || path.join(run.runDir, "evidence.json");
|
||
if (!await fileExists(evidencePath)) return { run, control, status, stage };
|
||
const completed = await writeRunControl(context, { ...run, status: "completed", stage: "completed", evidencePath }, { status: "completed", stage: "completed", completedAt: context.now(), evidencePath });
|
||
return { run: completed.run, control: completed.control, status: "completed", stage: statusStage(completed.run, completed.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: apiUrlFrom(context),
|
||
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 ok<T extends Record<string, unknown>>(action: string, extra: T, status = "succeeded") { return { ok: status !== "failed", action, status, ...extra }; }
|
||
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw cliError("missing_required_value", `${field} is required`, { field }); return result; }
|