From bc5cd8d4ccf11509ef2501c64171cc69e5f3347e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 05:51:52 +0200 Subject: [PATCH 1/2] feat: add CaseRun local and API transports --- .gitignore | 4 + internal/harnessrl/http.ts | 9 +- internal/harnessrl/transport.ts | 117 ++++++++++++++++ tools/hwlab-cli/caserun-transport.test.ts | 61 +++++++++ tools/hwlab-cli/caserun.test.ts | 160 ---------------------- tools/src/hwlab-caserun-runtime.ts | 53 ++++++- tools/src/hwlab-caserun-shared.ts | 1 + tools/src/hwlab-cli-lib.ts | 2 + 8 files changed, 234 insertions(+), 173 deletions(-) create mode 100644 internal/harnessrl/transport.ts create mode 100644 tools/hwlab-cli/caserun-transport.test.ts diff --git a/.gitignore b/.gitignore index be4a82c5..e4d6de38 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ tmp/ *.out vendor/ +# Python +__pycache__/ +*.pyc + # Node package managers, for future web work node_modules/ node_modules diff --git a/internal/harnessrl/http.ts b/internal/harnessrl/http.ts index 13aef424..5460b31c 100644 --- a/internal/harnessrl/http.ts +++ b/internal/harnessrl/http.ts @@ -1,3 +1,5 @@ +import { caseRunAccepted } from "./transport.ts"; + export function createHarnessRLHttpApp(options: { service: ReturnType }) { return { async fetch(request: Request) { @@ -13,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType 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; } 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 } }; } diff --git a/internal/harnessrl/transport.ts b/internal/harnessrl/transport.ts new file mode 100644 index 00000000..7eaa7deb --- /dev/null +++ b/internal/harnessrl/transport.ts @@ -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; + getRun(runId: string): Promise; + events(runId: string): Promise>; + aggregate(runId: string): Promise>; + cancel(runId: string): Promise; +}; + +export type CaseRunTransport = { + mode: "local" | "api"; + execute(command: CaseRunCommand): Promise>; + close(): Promise; +}; + +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 }): 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 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); +} diff --git a/tools/hwlab-cli/caserun-transport.test.ts b/tools/hwlab-cli/caserun-transport.test.ts new file mode 100644 index 00000000..fb31db07 --- /dev/null +++ b/tools/hwlab-cli/caserun-transport.test.ts @@ -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"); +}); diff --git a/tools/hwlab-cli/caserun.test.ts b/tools/hwlab-cli/caserun.test.ts index c65c73d5..5d231a6a 100644 --- a/tools/hwlab-cli/caserun.test.ts +++ b/tools/hwlab-cli/caserun.test.ts @@ -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-"))); } diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index 820ad213..d44a51fb 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -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 ", + 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 [--run-id RUN_ID]`, + "case run status RUN_ID [--over-api --base-url ]", + "case run events RUN_ID [--over-api --base-url ]", + "case run aggregate RUN_ID [--over-api --base-url ]", + "case run cancel RUN_ID [--over-api --base-url ]", `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); diff --git a/tools/src/hwlab-caserun-shared.ts b/tools/src/hwlab-caserun-shared.ts index 8e90e4be..f53e7a4d 100644 --- a/tools/src/hwlab-caserun-shared.ts +++ b/tools/src/hwlab-caserun-shared.ts @@ -21,6 +21,7 @@ export type CaseContext = { sleep: (ms: number) => Promise; runProcess?: RunProcessLike; startProcess?: StartProcessLike; + caseRunTransportFactory?: (context: CaseContext) => Promise | import("../../internal/harnessrl/transport.ts").CaseRunTransport; argv?: string[]; rest: string[]; }; diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 09ff8d28..411e4c7c 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -36,6 +36,7 @@ type CliOptions = { sleep?: (ms: number) => Promise; runProcess?: (command: string[], cwd: string, env: Record) => Promise<{ command: string[]; stdout: string; stderr: string; exitCode: number }>; startProcess?: (input: { command: string; args: string[]; cwd: string; env: Record; stdoutPath: string; stderrPath: string }) => Promise<{ pid: number }> | { pid: number }; + caseRunTransportFactory?: (context: any) => Promise | 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(); From de2d1f0d0c225b30c87487fcb05d6d22e4f40afa Mon Sep 17 00:00:00 2001 From: root Date: Fri, 17 Jul 2026 06:18:25 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=94=B6=E5=8F=A3=20CaseRun=20API?= =?UTF-8?q?=20=E8=B6=85=E6=97=B6=E4=B8=8E=E8=B7=AF=E5=BE=84=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/harnessrl/transport.ts | 65 ++++++++++++++++++----- tools/hwlab-cli/caserun-transport.test.ts | 30 +++++++++++ tools/src/hwlab-caserun-runtime.ts | 13 ++--- 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/internal/harnessrl/transport.ts b/internal/harnessrl/transport.ts index 7eaa7deb..90d122c2 100644 --- a/internal/harnessrl/transport.ts +++ b/internal/harnessrl/transport.ts @@ -17,10 +17,13 @@ export type HarnessRLService = { export type CaseRunTransport = { mode: "local" | "api"; + visibility(command: CaseRunCommand): Record; execute(command: CaseRunCommand): Promise>; close(): Promise; }; +const DEFAULT_API_TIMEOUT_MS = 30000; + export class CaseRunTransportError extends Error { code: string; details?: unknown; @@ -36,6 +39,9 @@ export class CaseRunTransportError extends Error { export function createLocalCaseRunTransport(options: { service: HarnessRLService; close?: () => Promise }): CaseRunTransport { return { mode: "local", + visibility() { + return { service: "native-harnessrl-application-service", baseUrl: null, route: null, method: null }; + }, async execute(command) { try { if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" })); @@ -51,26 +57,45 @@ export function createLocalCaseRunTransport(options: { service: HarnessRLService }; } -export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string }): CaseRunTransport { +export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?: typeof fetch; apiKey?: string; timeoutMs?: number }): CaseRunTransport { const baseUrl = apiOrigin(options.baseUrl); const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = apiTimeoutMs(options.timeoutMs); return { mode: "api", + visibility(command) { + return apiRequestVisibility(baseUrl, command); + }, 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 }); + const request = apiRequestVisibility(baseUrl, command); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(`${baseUrl}${request.route}`, { + method: request.method, + signal: controller.signal, + 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 + }); + const body = await response.json().catch((error) => { + if (controller.signal.aborted) throw error; + return 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; + } catch (error) { + if (controller.signal.aborted) { + throw new CaseRunTransportError("caserun_api_timeout", `CaseRun API request timed out after ${timeoutMs}ms`, { timeoutMs, baseUrl, route: request.route, method: request.method }); + } + throw typedError(error, "caserun_api_unavailable"); + } finally { + clearTimeout(timer); } - return body; }, close: async () => {} }; @@ -111,6 +136,18 @@ function apiOrigin(value: string) { } } +function apiTimeoutMs(value: number | undefined) { + const timeoutMs = value ?? DEFAULT_API_TIMEOUT_MS; + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) throw new CaseRunTransportError("invalid_caserun_timeout", "--timeout-ms must be a positive integer", { timeoutMs }); + return timeoutMs; +} + +function apiRequestVisibility(baseUrl: string, command: CaseRunCommand) { + const route = command.kind === "start" ? "/v1/caserun/runs" : `/v1/caserun/runs/${encodeURIComponent(command.runId)}${command.kind === "status" ? "" : `/${command.kind}`}`; + const method = command.kind === "start" || command.kind === "cancel" ? "POST" : "GET"; + return { baseUrl, route, method }; +} + function typedError(error: any, fallbackCode: string) { if (error instanceof CaseRunTransportError) return error; return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details); diff --git a/tools/hwlab-cli/caserun-transport.test.ts b/tools/hwlab-cli/caserun-transport.test.ts index fb31db07..5be1110a 100644 --- a/tools/hwlab-cli/caserun-transport.test.ts +++ b/tools/hwlab-cli/caserun-transport.test.ts @@ -39,6 +39,10 @@ test("CLI defaults to local and requires explicit over-api for base-url", async 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.service, "native-harnessrl-application-service"); + assert.equal(result.payload.baseUrl, null); + assert.equal(result.payload.route, null); + assert.equal(result.payload.method, null); assert.equal(result.payload.workflowRunId, record.workflowRunId); const rejected = await runHwlabCli(["case", "run", "status", record.runId, "--base-url", "http://api.test"]); @@ -59,3 +63,29 @@ test("API adapter uses only the explicit CaseRun origin and preserves typed erro 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"); }); + +test("API adapter times out through its real AbortSignal path with typed route details", async () => { + const transport = createApiCaseRunTransport({ baseUrl: "http://api.test", timeoutMs: 5, fetchImpl: async (_input, init) => { + await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")), { once: true })); + throw new Error("unreachable"); + }}); + await assert.rejects(transport.execute({ kind: "status", runId: record.runId }), (error: any) => { + assert.equal(error instanceof CaseRunTransportError, true); + assert.equal(error.code, "caserun_api_timeout"); + assert.deepEqual(error.details, { timeoutMs: 5, baseUrl: "http://api.test", route: `/v1/caserun/runs/${record.runId}`, method: "GET" }); + return true; + }); +}); + +test("over-api CLI discloses bounded origin route and method without credentials", async () => { + const result = await runHwlabCli(["case", "run", "events", record.runId, "--over-api", "--base-url", "http://api.test", "--timeout-ms", "25"], { + env: { HWLAB_API_KEY: "hwl_live_secret" }, + fetchImpl: async () => Response.json({ ok: true, ...record, events: [] }) + }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.transport, "api"); + assert.equal(result.payload.baseUrl, "http://api.test"); + assert.equal(result.payload.route, `/v1/caserun/runs/${record.runId}/events`); + assert.equal(result.payload.method, "GET"); + assert.equal(JSON.stringify(result.payload).includes("hwl_live_secret"), false); +}); diff --git a/tools/src/hwlab-caserun-runtime.ts b/tools/src/hwlab-caserun-runtime.ts index d44a51fb..c531cf48 100644 --- a/tools/src/hwlab-caserun-runtime.ts +++ b/tools/src/hwlab-caserun-runtime.ts @@ -83,11 +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 start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url [--run-id RUN_ID]`, - "case run status RUN_ID [--over-api --base-url ]", - "case run events RUN_ID [--over-api --base-url ]", - "case run aggregate RUN_ID [--over-api --base-url ]", - "case run cancel RUN_ID [--over-api --base-url ]", + `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url [--timeout-ms 30000] [--run-id RUN_ID]`, + "case run status RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run events RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run aggregate RUN_ID [--over-api --base-url --timeout-ms 30000]", + "case run cancel RUN_ID [--over-api --base-url --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]` @@ -116,6 +116,7 @@ async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunComman return ok(`case.run.${kind}`, { transport: transport.mode, command: kind, + ...transport.visibility(command), ...response, ...(kind === "start" ? { startReturned: true } : {}) }, kind === "start" ? "submitted" : "succeeded"); @@ -125,7 +126,7 @@ async function harnessRLCaseRunCommand(context: CaseContext, kind: CaseRunComman } 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.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,