feat: add CaseRun local and API transports

This commit is contained in:
root
2026-07-17 05:51:52 +02:00
parent 32be75b48d
commit bc5cd8d4cc
8 changed files with 234 additions and 173 deletions
+4
View File
@@ -26,6 +26,10 @@ tmp/
*.out
vendor/
# Python
__pycache__/
*.pyc
# Node package managers, for future web work
node_modules/
node_modules
+3 -6
View File
@@ -1,3 +1,5 @@
import { caseRunAccepted } from "./transport.ts";
export function createHarnessRLHttpApp(options: { service: ReturnType<typeof import("./service.ts")["createHarnessRLService"]> }) {
return {
async fetch(request: Request) {
@@ -13,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
if (url.pathname === "/v1/caserun/runs" && request.method === "POST") {
const body = await bodyObject(request);
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, runtimeApiUrl: runtimeApiUrl(request) });
return json(202, accepted(record));
return json(202, caseRunAccepted(record));
}
const match = /^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u.exec(url.pathname);
if (match) {
@@ -36,11 +38,6 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
};
}
function accepted(record: any) {
return { ok: true, contractVersion: "hwlab-caserun-web-v1", runId: record.runId, caseId: record.caseId, status: record.status, stage: record.stage,
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`, eventsUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`,
aggregateUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`, cancelUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/cancel` };
}
async function bodyObject(request: Request) { const body = await request.json().catch(() => null); if (!body || typeof body !== "object" || Array.isArray(body)) throw Object.assign(new Error("JSON object body is required"), { code: "invalid_json" }); return body as Record<string, unknown>; }
function runtimeApiUrl(request: Request) { const url = new URL(request.url); return `${url.protocol}//${url.host}`; }
function errorBody(code: string, message: string, details?: unknown) { return { ok: false, error: { code, message, details } }; }
+117
View File
@@ -0,0 +1,117 @@
import type { CaseRunRecord } from "./contracts.ts";
export type CaseRunCommand =
| { kind: "start"; caseId: string; runId?: string }
| { kind: "status"; runId: string }
| { kind: "events"; runId: string }
| { kind: "aggregate"; runId: string }
| { kind: "cancel"; runId: string };
export type HarnessRLService = {
submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }): Promise<CaseRunRecord | null>;
getRun(runId: string): Promise<CaseRunRecord>;
events(runId: string): Promise<Record<string, unknown>>;
aggregate(runId: string): Promise<Record<string, unknown>>;
cancel(runId: string): Promise<CaseRunRecord | null>;
};
export type CaseRunTransport = {
mode: "local" | "api";
execute(command: CaseRunCommand): Promise<Record<string, unknown>>;
close(): Promise<void>;
};
export class CaseRunTransportError extends Error {
code: string;
details?: unknown;
constructor(code: string, message: string, details?: unknown) {
super(message);
this.name = "CaseRunTransportError";
this.code = code;
this.details = details;
}
}
export function createLocalCaseRunTransport(options: { service: HarnessRLService; close?: () => Promise<void> }): CaseRunTransport {
return {
mode: "local",
async execute(command) {
try {
if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" }));
if (command.kind === "status") return options.service.getRun(command.runId);
if (command.kind === "events") return options.service.events(command.runId);
if (command.kind === "aggregate") return options.service.aggregate(command.runId);
return acceptedRecord(await options.service.cancel(command.runId));
} catch (error) {
throw typedError(error, "harnessrl_error");
}
},
close: options.close ?? (async () => {})
};
}
export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string }): CaseRunTransport {
const baseUrl = apiOrigin(options.baseUrl);
const fetchImpl = options.fetchImpl ?? fetch;
return {
mode: "api",
async execute(command) {
const runPath = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`;
const response = await fetchImpl(`${baseUrl}${runPath}`, {
method: command.kind === "start" || command.kind === "cancel" ? "POST" : "GET",
headers: {
...(command.kind === "start" ? { "content-type": "application/json" } : {}),
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {})
},
body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined
}).catch((error) => { throw typedError(error, "caserun_api_unavailable"); });
const body = await response.json().catch(() => null) as any;
if (!response.ok || !body || typeof body !== "object") {
throw new CaseRunTransportError(body?.error?.code ?? "caserun_api_error", body?.error?.message ?? `CaseRun API returned HTTP ${response.status}`, body?.error?.details ?? { status: response.status });
}
return body;
},
close: async () => {}
};
}
export function caseRunAccepted(record: CaseRunRecord | null) {
if (!record) throw new CaseRunTransportError("caserun_start_failed", "CaseRun start did not return a run record");
return {
ok: true,
contractVersion: "hwlab-caserun-web-v1",
runId: record.runId,
caseId: record.caseId,
workflowId: record.workflowId,
workflowRunId: record.workflowRunId,
status: record.status,
stage: record.stage,
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`,
eventsUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/events`,
aggregateUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/aggregate`,
cancelUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}/cancel`
};
}
function acceptedRecord(record: CaseRunRecord | null) {
if (!record) throw new CaseRunTransportError("caserun_cancel_failed", "CaseRun cancel did not return a run record");
return record;
}
function apiOrigin(value: string) {
const text = String(value ?? "").trim();
if (!text) throw new CaseRunTransportError("caserun_base_url_required", "--base-url <origin> is required with --over-api");
try {
const url = new URL(text);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.pathname !== "/" || url.search || url.hash) throw new Error("origin required");
return url.origin;
} catch {
throw new CaseRunTransportError("invalid_caserun_base_url", "--base-url must be an absolute HTTP(S) origin", { baseUrl: text });
}
}
function typedError(error: any, fallbackCode: string) {
if (error instanceof CaseRunTransportError) return error;
return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details);
}
+61
View File
@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { test } from "bun:test";
import type { CaseRunRecord } from "../../internal/harnessrl/contracts.ts";
import { CaseRunTransportError, createApiCaseRunTransport, createLocalCaseRunTransport } from "../../internal/harnessrl/transport.ts";
import { runHwlabCli } from "../src/hwlab-cli-lib.ts";
const record: CaseRunRecord = {
runId: "run-2617", caseId: "case-2617", workflowId: "harnessrl-caserun-run-2617", workflowRunId: "workflow-run-2617",
status: "queued", stage: "queued", terminal: false, caseRepo: "/cases", runDir: "/state/run-2617", runtimeApiUrl: "local://harnessrl",
result: null, error: null, blocker: null, createdAt: "2026-07-17T00:00:00.000Z", updatedAt: "2026-07-17T00:00:00.000Z"
};
test("L0 local adapter executes the shared CaseRun command contract without HTTP", async () => {
const calls: string[] = [];
const transport = createLocalCaseRunTransport({ service: {
async submit() { calls.push("start"); return record; },
async getRun() { calls.push("status"); return record; },
async events() { calls.push("events"); return { ...record, events: [] }; },
async aggregate() { calls.push("aggregate"); return { ok: true, runId: record.runId, status: record.status }; },
async cancel() { calls.push("cancel"); return { ...record, status: "cancel_requested", stage: "cancel_requested" }; }
}});
const started = await transport.execute({ kind: "start", caseId: record.caseId, runId: record.runId });
assert.equal(started.runId, record.runId);
assert.equal(started.workflowId, record.workflowId);
await transport.execute({ kind: "status", runId: record.runId });
await transport.execute({ kind: "events", runId: record.runId });
await transport.execute({ kind: "aggregate", runId: record.runId });
await transport.execute({ kind: "cancel", runId: record.runId });
assert.deepEqual(calls, ["start", "status", "events", "aggregate", "cancel"]);
});
test("CLI defaults to local and requires explicit over-api for base-url", async () => {
const local = createLocalCaseRunTransport({ service: {
async submit() { return record; }, async getRun() { return record; }, async events() { return { ...record, events: [] }; },
async aggregate() { return { ok: true, runId: record.runId }; }, async cancel() { return record; }
}});
const result = await runHwlabCli(["case", "run", "start", record.caseId, "--run-id", record.runId], { caseRunTransportFactory: () => local });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.transport, "local");
assert.equal(result.payload.workflowRunId, record.workflowRunId);
const rejected = await runHwlabCli(["case", "run", "status", record.runId, "--base-url", "http://api.test"]);
assert.equal(rejected.exitCode, 1);
assert.equal(rejected.payload.error.code, "caserun_over_api_required");
});
test("API adapter uses only the explicit CaseRun origin and preserves typed errors", async () => {
const requests: Array<{ url: string; method: string; authorization: string | null }> = [];
const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", apiKey: "hwl_live_test", fetchImpl: async (input, init) => {
requests.push({ url: String(input), method: String(init?.method), authorization: new Headers(init?.headers).get("authorization") });
return Response.json({ ok: true, ...record });
}});
const response = await transport.execute({ kind: "events", runId: record.runId });
assert.equal(response.workflowId, record.workflowId);
assert.deepEqual(requests, [{ url: `http://api.test/v1/caserun/runs/${record.runId}/events`, method: "GET", authorization: "Bearer hwl_live_test" }]);
const failed = createApiCaseRunTransport({ baseUrl: "http://api.test", fetchImpl: async () => Response.json({ ok: false, error: { code: "caserun_run_not_found", message: "missing", details: { runId: record.runId } } }, { status: 404 }) });
await assert.rejects(failed.execute({ kind: "status", runId: record.runId }), (error: any) => error instanceof CaseRunTransportError && error.code === "caserun_run_not_found");
});
-160
View File
@@ -224,79 +224,6 @@ test("case run normalizes v0.3 HWPOD build operation topology and typed wait blo
assert.equal(yamlEmpty.topology.ok, true);
});
test("case run start returns immediately and status/result/logs are short polling commands", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
const cwd = path.join(root, "hwlab");
const caseId = "d601-f103-v2-compile";
try {
await createCaseRepo(caseRepo, caseId);
await mkdir(cwd, { recursive: true });
let workerArgs: string[] = [];
const started = await runHwlabCli([
"case",
"run",
"start",
caseId,
"--case-repo",
caseRepo,
"--run-id",
"run-async",
"--agent-timeout-ms",
"180000",
"--job-timeout-ms",
"70000"
], {
cwd,
now: () => "2026-06-05T00:00:00.000Z",
env: TEST_RUNTIME_ENV,
startProcess: async (input) => {
workerArgs = input.args;
await writeFile(input.stdoutPath, "worker accepted\n", "utf8");
await writeFile(input.stderrPath, "", "utf8");
return { pid: 4242 };
}
});
assert.equal(started.exitCode, 0);
assert.equal(started.payload.action, "case.run.start");
assert.equal(started.payload.status, "submitted");
assert.equal(started.payload.startReturned, true);
assert.equal(started.payload.pid, 4242);
assert.match(started.payload.statusCommand, /case run status run-async/u);
assert.match(started.payload.resultCommand, /case run result run-async/u);
assert.match(started.payload.logsCommand, /case run logs run-async/u);
assert.equal(workerArgs.includes("--agent-timeout-ms"), true);
assert.equal(workerArgs[workerArgs.indexOf("--agent-timeout-ms") + 1], "180000");
assert.equal(workerArgs.includes("--job-timeout-ms"), true);
assert.equal(workerArgs[workerArgs.indexOf("--job-timeout-ms") + 1], "70000");
const status = await runHwlabCli(["case", "run", "status", "run-async"], { cwd, now: () => "2026-06-05T00:00:01.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.action, "case.run.status");
assert.equal(status.payload.status, "running");
assert.equal(status.payload.stage, "started");
assert.equal(status.payload.pid, 4242);
assert.equal(status.payload.stdoutBytes, Buffer.byteLength("worker accepted\n"));
assert.equal(status.payload.stderrBytes, 0);
assert.match(status.payload.nextPollCommand, /case run status run-async/u);
const result = await runHwlabCli(["case", "run", "result", "run-async"], { cwd, now: () => "2026-06-05T00:00:02.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "case.run.result");
assert.equal(result.payload.ready, false);
assert.equal(result.payload.status, "running");
assert.match(result.payload.nextPollCommand, /case run status run-async/u);
const logs = await runHwlabCli(["case", "run", "logs", "run-async", "--tail", "200"], { cwd, now: () => "2026-06-05T00:00:03.000Z" });
assert.equal(logs.exitCode, 0);
assert.equal(logs.payload.action, "case.run.logs");
assert.equal(logs.payload.stdout, "worker accepted\n");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case run result finalizes legacy build-completed state and refreshes registry artifacts", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
@@ -738,56 +665,6 @@ test("case run archives rendered trace transcript when finalResponse is null", a
}
});
test("case run status reports latest run stage when async control stage is stale", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-stale-stage");
try {
await mkdir(runDir, { recursive: true });
await writeFile(path.join(runDir, "worker.stdout.log"), "agent finished\n", "utf8");
await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8");
await writeFile(path.join(runDir, "run.json"), JSON.stringify({
caseId: "d601-f103-v2-compile",
runId: "run-stale-stage",
runDir,
caseRepo: "",
caseDir: "",
caseFile: "",
sourceSpecPath: "",
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: "http://api.test",
compileOnly: true,
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:10.000Z",
status: "running",
stage: "agent-running",
definition: {},
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-stale-stage", workspacePath: "", setup: {} },
agent: { stageStatus: "completed", baseUrl: "http://web.test", projectId: "prj_hwpod_workbench", providerProfile: "deepseek", conversationId: "cnv_case", sessionId: "ses_case", threadId: "thread-case", traceId: "trc_case", promptPath: path.join(runDir, "agent-prompt.md"), promptSha256: "sha", polls: 3, lastHttpStatus: 200 },
agentDiff: { statusShort: " M projects/01_baseline/main.c\n", diffStat: " projects/01_baseline/main.c | 1 +\n", diffPatchPath: path.join(runDir, "agent-diff.patch"), diffPatchSha256: "sha" },
_control: { status: "running", stage: "prepared", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" }
}, null, 2), "utf8");
const status = await runHwlabCli(["case", "run", "status", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:12.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.stage, "agent-diff-collected");
assert.equal(status.payload.staleControlWarning.code, "stale_control_stage");
assert.equal(status.payload.staleControlWarning.controlStage, "prepared");
assert.equal(status.payload.staleControlWarning.selectedStage, "agent-diff-collected");
assert.equal(status.payload.agent.stageStatus, "completed");
assert.match(status.payload.agentDiff.statusShort, /projects\/01_baseline\/main\.c/u);
const result = await runHwlabCli(["case", "run", "result", "run-stale-stage"], { cwd, now: () => "2026-06-05T00:00:13.000Z" });
assert.equal(result.exitCode, 0);
assert.equal(result.payload.ready, false);
assert.equal(result.payload.stage, "agent-diff-collected");
assert.equal(result.payload.traceLookup.commands.trace, "hwlab-cli client agent trace trc_case --render web");
assert.equal(result.payload.staleControlWarning.code, "stale_control_stage");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("case aggregate writes one registry markdown entry without grading or broad registry commits", async () => {
const root = await mkdtempCaseRoot();
const caseRepo = path.join(root, "hwlab-case-registry");
@@ -909,43 +786,6 @@ test("case aggregate writes one registry markdown entry without grading or broad
}
});
test("case run status keeps worker control stage ahead of prepared worktree evidence", async () => {
const root = await mkdtempCaseRoot();
const cwd = path.join(root, "hwlab");
const runDir = path.join(cwd, ".state", "hwlab-cli", "caserun", "run-worker-started");
try {
await mkdir(runDir, { recursive: true });
await writeFile(path.join(runDir, "worker.stdout.log"), "", "utf8");
await writeFile(path.join(runDir, "worker.stderr.log"), "", "utf8");
await writeFile(path.join(runDir, "run.json"), JSON.stringify({
caseId: "d601-f103-v2-compile",
runId: "run-worker-started",
runDir,
caseRepo: "",
caseDir: "",
caseFile: "",
sourceSpecPath: "",
specPath: path.join(runDir, ".hwlab", "hwpod-spec.yaml"),
apiUrl: "http://api.test",
compileOnly: true,
createdAt: "2026-06-05T00:00:00.000Z",
updatedAt: "2026-06-05T00:00:10.000Z",
status: "running",
stage: "queued",
definition: {},
subject: { repoLocalPath: SUBJECT_REPO_LOCAL_PATH, commitId: SUBJECT_COMMIT_ID, subdir: "projects/01_baseline", worktreePath: "F:\\Work\\HWLAB-CASE-F103\\.worktree\\caserun-run-worker-started", workspacePath: "", setup: {} },
_control: { status: "running", stage: "started", pid: 1234, stdoutPath: path.join(runDir, "worker.stdout.log"), stderrPath: path.join(runDir, "worker.stderr.log"), startedAt: "2026-06-05T00:00:00.000Z" }
}, null, 2), "utf8");
const status = await runHwlabCli(["case", "run", "status", "run-worker-started"], { cwd, now: () => "2026-06-05T00:00:12.000Z" });
assert.equal(status.exitCode, 0);
assert.equal(status.payload.stage, "started");
assert.equal(status.payload.staleControlWarning, undefined);
} finally {
await rm(root, { recursive: true, force: true });
}
});
async function mkdtempCaseRoot() {
return await import("node:fs/promises").then(({ mkdtemp }) => mkdtemp(path.join(os.tmpdir(), "hwlab-caserun-")));
}
+46 -7
View File
@@ -13,6 +13,8 @@ import { inspectCaseRegistryRun, refreshCaseRegistryArtifacts, skillUsageCaseReg
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";
@@ -45,8 +47,7 @@ export async function caseCommand(context: CaseContext) {
async function caseRunCommand(context: CaseContext) {
const mode = context.rest[1] || "";
if (mode === "start") return startCaseRun(context);
if (mode === "status") return statusCaseRun(context);
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");
@@ -58,7 +59,11 @@ async function caseRunCommand(context: CaseContext) {
export function caseHelp() {
return ok("case.help", {
serviceRuntime: false,
runTransport: {
default: "local HarnessRL application service",
overApi: "explicit --over-api --base-url <origin>",
fallback: false
},
compileOnlyDefault: true,
agentTaskRequiredForRun: false,
autoEvaluation: false,
@@ -78,9 +83,11 @@ export function caseHelp() {
`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 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]`,
`case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url <origin> [--run-id RUN_ID]`,
"case run status RUN_ID [--over-api --base-url <origin>]",
"case run events RUN_ID [--over-api --base-url <origin>]",
"case run aggregate RUN_ID [--over-api --base-url <origin>]",
"case run cancel RUN_ID [--over-api --base-url <origin>]",
`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]`
@@ -92,12 +99,44 @@ export function caseHelp() {
"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/result/logs is the cli-spec async control surface for long CaseRun flows; start returns immediately and status/result/logs are short polling commands.",
"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,
...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 });
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);
+1
View File
@@ -21,6 +21,7 @@ export type CaseContext = {
sleep: (ms: number) => Promise<void>;
runProcess?: RunProcessLike;
startProcess?: StartProcessLike;
caseRunTransportFactory?: (context: CaseContext) => Promise<import("../../internal/harnessrl/transport.ts").CaseRunTransport> | import("../../internal/harnessrl/transport.ts").CaseRunTransport;
argv?: string[];
rest: string[];
};
+2
View File
@@ -36,6 +36,7 @@ type CliOptions = {
sleep?: (ms: number) => Promise<void>;
runProcess?: (command: string[], cwd: string, env: Record<string, string | undefined>) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>;
startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record<string, string | undefined>; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number };
caseRunTransportFactory?: (context: any) => Promise<any> | any;
};
export async function main(argv = process.argv.slice(2), options: CliOptions = {}) {
@@ -60,6 +61,7 @@ export async function runHwlabCli(argv: string[], options: CliOptions = {}) {
sleep: options.sleep ?? wait,
runProcess: options.runProcess,
startProcess: options.startProcess,
caseRunTransportFactory: options.caseRunTransportFactory,
argv
};
const payload = target === "client" ? await clientCommand({ ...context, rest: parsed._.slice(1) }) : target === "case" ? await caseCommand({ ...context, rest: parsed._.slice(1) }) : help();