feat: add CaseRun local and API transports
This commit is contained in:
@@ -26,6 +26,10 @@ tmp/
|
||||
*.out
|
||||
vendor/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Node package managers, for future web work
|
||||
node_modules/
|
||||
node_modules
|
||||
|
||||
@@ -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 } }; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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-")));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user