feat: add CaseRun local and API transports
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { caseRunAccepted } from "./transport.ts";
|
||||
|
||||
export function createHarnessRLHttpApp(options: { service: ReturnType<typeof import("./service.ts")["createHarnessRLService"]> }) {
|
||||
return {
|
||||
async fetch(request: Request) {
|
||||
@@ -13,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
|
||||
if (url.pathname === "/v1/caserun/runs" && request.method === "POST") {
|
||||
const body = await bodyObject(request);
|
||||
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, runtimeApiUrl: runtimeApiUrl(request) });
|
||||
return json(202, accepted(record));
|
||||
return json(202, caseRunAccepted(record));
|
||||
}
|
||||
const match = /^\/v1\/caserun\/runs\/([^/]+)(?:\/(events|aggregate|cancel))?$/u.exec(url.pathname);
|
||||
if (match) {
|
||||
@@ -36,11 +38,6 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
|
||||
};
|
||||
}
|
||||
|
||||
function accepted(record: any) {
|
||||
return { ok: true, contractVersion: "hwlab-caserun-web-v1", runId: record.runId, caseId: record.caseId, 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` };
|
||||
}
|
||||
async function bodyObject(request: Request) { const body = await request.json().catch(() => null); if (!body || typeof body !== "object" || Array.isArray(body)) throw Object.assign(new Error("JSON object body is required"), { code: "invalid_json" }); return body as Record<string, unknown>; }
|
||||
function runtimeApiUrl(request: Request) { const url = new URL(request.url); return `${url.protocol}//${url.host}`; }
|
||||
function errorBody(code: string, message: string, details?: unknown) { return { ok: false, error: { code, message, details } }; }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
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";
|
||||
execute(command: CaseRunCommand): Promise<Record<string, unknown>>;
|
||||
close(): Promise<void>;
|
||||
};
|
||||
|
||||
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",
|
||||
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 }): CaseRunTransport {
|
||||
const baseUrl = apiOrigin(options.baseUrl);
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
return {
|
||||
mode: "api",
|
||||
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 });
|
||||
}
|
||||
return body;
|
||||
},
|
||||
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 typedError(error: any, fallbackCode: string) {
|
||||
if (error instanceof CaseRunTransportError) return error;
|
||||
return new CaseRunTransportError(error?.code ?? fallbackCode, error?.message ?? String(error), error?.details);
|
||||
}
|
||||
Reference in New Issue
Block a user