Files
Hunter B 26925ae644 feat(runtime-sdk): add fleet helper client
Refs #3163.

Adds the @codewhale/runtime-sdk workspace with typed fleet Runtime API helpers, protocol-shaped TypeScript declarations, JSON/SSE event fixture handling, and typed RuntimeCapabilityError failures for create/event-stream endpoints that the Rust API has not exposed yet.

Documents the SDK contract in docs/RUNTIME_API.md and wires npm workspace verification through npm test --workspace @codewhale/runtime-sdk.
2026-06-12 22:17:16 -07:00

199 lines
5.5 KiB
JavaScript

const DEFAULT_BASE_URL = "http://127.0.0.1:7878";
export class RuntimeApiError extends Error {
constructor(message, options = {}) {
super(message);
this.name = "RuntimeApiError";
this.status = options.status;
this.method = options.method;
this.path = options.path;
this.body = options.body;
}
}
export class RuntimeCapabilityError extends RuntimeApiError {
constructor(capability, message, options = {}) {
super(message, options);
this.name = "RuntimeCapabilityError";
this.capability = capability;
}
}
export class CodeWhaleRuntimeClient {
constructor(options = {}) {
this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
this.token = options.token ?? null;
this.fetchImpl = options.fetch ?? globalThis.fetch;
if (typeof this.fetchImpl !== "function") {
throw new TypeError("CodeWhaleRuntimeClient requires a fetch implementation");
}
}
async createFleetRun(spec) {
return this.#jsonRequest("/v1/fleet/runs", {
method: "POST",
body: spec,
capability: "fleet_run_create",
});
}
async listFleetRuns() {
return this.#jsonRequest("/v1/fleet/runs");
}
async getFleetRun(runId) {
return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}`);
}
async listFleetWorkers(runId) {
return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}/workers`);
}
async getFleetWorker(workerId) {
return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}`);
}
async interruptWorker(workerId) {
return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}/interrupt`, {
method: "POST",
});
}
async restartWorker(workerId) {
return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}/restart`, {
method: "POST",
});
}
async stopFleetRun(runId) {
return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}/stop`, {
method: "POST",
});
}
async *fleetEvents(runId, options = {}) {
const path = options.path ?? `/v1/fleet/runs/${segment(runId)}/events`;
const response = await this.#rawRequest(path, {
method: "GET",
capability: "fleet_event_stream",
});
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const payload = await response.json();
const events = Array.isArray(payload) ? payload : (payload.events ?? []);
for (const event of events) {
yield event;
}
return;
}
if (!response.body) {
throw new RuntimeApiError("Runtime API event response did not include a readable body", {
method: "GET",
path,
});
}
for await (const event of parseEventStream(response.body)) {
yield event;
}
}
async #jsonRequest(path, options = {}) {
const response = await this.#rawRequest(path, options);
if (response.status === 204) {
return null;
}
return response.json();
}
async #rawRequest(path, options = {}) {
const method = options.method ?? "GET";
const headers = new Headers(options.headers);
headers.set("accept", options.accept ?? "application/json");
if (this.token) {
headers.set("authorization", `Bearer ${this.token}`);
}
const init = { method, headers };
if (options.body !== undefined) {
headers.set("content-type", "application/json");
init.body = JSON.stringify(options.body);
}
const response = await this.fetchImpl(new URL(path, this.baseUrl), init);
if (response.ok) {
return response;
}
const body = await readErrorBody(response);
const errorOptions = { status: response.status, method, path, body };
if (options.capability && [404, 405, 501].includes(response.status)) {
throw new RuntimeCapabilityError(
options.capability,
`Runtime API capability '${options.capability}' is not available at ${method} ${path}`,
errorOptions,
);
}
throw new RuntimeApiError(
`Runtime API request failed (${response.status}) for ${method} ${path}`,
errorOptions,
);
}
}
export function createRuntimeClient(options = {}) {
return new CodeWhaleRuntimeClient(options);
}
function normalizeBaseUrl(value) {
return value.endsWith("/") ? value : `${value}/`;
}
function segment(value) {
if (value === null || value === undefined || String(value).trim() === "") {
throw new TypeError("Runtime API path segment must be a non-empty value");
}
return encodeURIComponent(String(value));
}
async function readErrorBody(response) {
try {
const text = await response.text();
return text.length > 4096 ? `${text.slice(0, 4096)}...` : text;
} catch {
return "";
}
}
async function* parseEventStream(body) {
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of body) {
buffer += decoder.decode(chunk, { stream: true });
let boundary;
while ((boundary = buffer.indexOf("\n\n")) >= 0) {
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
const event = parseSseFrame(frame);
if (event !== undefined) {
yield event;
}
}
}
buffer += decoder.decode();
const event = parseSseFrame(buffer);
if (event !== undefined) {
yield event;
}
}
function parseSseFrame(frame) {
const data = frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart())
.join("\n");
if (!data || data === "[DONE]") {
return undefined;
}
return JSON.parse(data);
}