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,