Files

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);
}