fix: 收口 CaseRun API 超时与路径可见性

This commit is contained in:
root
2026-07-17 06:18:25 +02:00
parent bc5cd8d4cc
commit de2d1f0d0c
3 changed files with 88 additions and 20 deletions
+51 -14
View File
@@ -17,10 +17,13 @@ export type HarnessRLService = {
export type CaseRunTransport = {
mode: "local" | "api";
visibility(command: CaseRunCommand): Record<string, unknown>;
execute(command: CaseRunCommand): Promise<Record<string, unknown>>;
close(): Promise<void>;
};
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<void> }): 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);
+30
View File
@@ -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);
});
+7 -6
View File
@@ -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 <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 start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --over-api --base-url <origin> [--timeout-ms 30000] [--run-id RUN_ID]`,
"case run status RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run events RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run aggregate RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
"case run cancel RUN_ID [--over-api --base-url <origin> --timeout-ms 30000]",
`case run inspect RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run trace-summary RUN_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`,
`case run skill-usage RUN_ID --skill SKILL_NAME --case-repo ${STANDARD_CASE_REPO_PATH} [--case-id CASE_ID]`
@@ -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,