fix: 收口 CaseRun API 超时与路径可见性
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user