155 lines
6.5 KiB
TypeScript
155 lines
6.5 KiB
TypeScript
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";
|
|
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;
|
|
|
|
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",
|
|
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" }));
|
|
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; 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 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);
|
|
}
|
|
},
|
|
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 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);
|
|
}
|