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.
This commit is contained in:
Hunter B
2026-06-12 22:17:16 -07:00
parent e8f6816472
commit 26925ae644
8 changed files with 799 additions and 0 deletions
+45
View File
@@ -450,6 +450,51 @@ User-supplied origins **stack on top of** the built-in defaults; they do not
replace them. Wildcard origins are not supported — the explicit allow-list
model is preserved. Added in v0.8.10 (#561).
## Runtime SDK Fleet Helpers
The v0.8.60 Runtime SDK fixture lives in `npm/runtime-sdk` and is exposed as
the `@codewhale/runtime-sdk` workspace package. It is deliberately thin: every
helper calls the local Rust Runtime API and therefore cannot bypass CodeWhale's
sandbox, approval prompts, provider configuration, or fleet ledger authority.
```js
import { createRuntimeClient } from "@codewhale/runtime-sdk";
const client = createRuntimeClient({
baseUrl: "http://127.0.0.1:7878",
token: process.env.CODEWHALE_RUNTIME_TOKEN,
});
const { runs } = await client.listFleetRuns();
const workers = await client.listFleetWorkers(runs[0].id);
await client.restartWorker(workers.workers[0].worker_id);
```
Fleet helpers cover the v0.8.60 HTTP surface:
| Helper | Runtime API route |
|---|---|
| `listFleetRuns()` | `GET /v1/fleet/runs` |
| `getFleetRun(runId)` | `GET /v1/fleet/runs/{run_id}` |
| `listFleetWorkers(runId)` | `GET /v1/fleet/runs/{run_id}/workers` |
| `getFleetWorker(workerId)` | `GET /v1/fleet/workers/{worker_id}` |
| `interruptWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/interrupt` |
| `restartWorker(workerId)` | `POST /v1/fleet/workers/{worker_id}/restart` |
| `stopFleetRun(runId)` | `POST /v1/fleet/runs/{run_id}/stop` |
`createFleetRun(spec)` and `fleetEvents(runId)` are typed ahead of the current
Rust routes so editor/web clients can code against the intended SDK contract.
Until the Runtime API exposes `POST /v1/fleet/runs` and a fleet event stream,
the SDK raises `RuntimeCapabilityError` with stable capability strings
(`fleet_run_create`, `fleet_event_stream`) instead of surfacing those gaps as
generic fetch failures.
Verification:
```bash
npm test --workspace @codewhale/runtime-sdk
```
## Session lifecycle (native UI supervision)
| Operation | Endpoint |
+35
View File
@@ -0,0 +1,35 @@
# @codewhale/runtime-sdk
Small JavaScript helpers and TypeScript declarations for CodeWhale's local
Runtime API. The package is intentionally transport-only: it never bypasses the
Rust runtime, sandbox, approvals, provider configuration, or fleet ledger.
```js
import { createRuntimeClient } from "@codewhale/runtime-sdk";
const client = createRuntimeClient({
baseUrl: "http://127.0.0.1:7878",
token: process.env.CODEWHALE_RUNTIME_TOKEN,
});
const { runs } = await client.listFleetRuns();
const workers = await client.listFleetWorkers(runs[0].id);
await client.interruptWorker(workers.workers[0].worker_id);
```
## Fleet Helpers
- `listFleetRuns()`
- `getFleetRun(runId)`
- `listFleetWorkers(runId)`
- `getFleetWorker(workerId)`
- `interruptWorker(workerId)`
- `restartWorker(workerId)`
- `stopFleetRun(runId)`
- `fleetEvents(runId)`
- `createFleetRun(spec)`
`fleetEvents` and `createFleetRun` are typed ahead of the current v0.8.60 Rust
Runtime API. If the local runtime does not expose those endpoints, the helpers
raise `RuntimeCapabilityError` with a stable `capability` string instead of a
generic fetch failure.
+245
View File
@@ -0,0 +1,245 @@
export type FleetRunId = string;
export type FleetRunStatus =
| "pending"
| "queued"
| "running"
| "paused"
| "completed"
| "failed"
| "cancelled";
export type FleetWorkerStatus =
| "unknown"
| "online"
| "busy"
| "offline"
| "unhealthy"
| "draining"
| "retired";
export type FleetArtifactKind =
| "log"
| "patch"
| "test_result"
| "report"
| "checkpoint"
| "receipt"
| string;
export interface FleetStatusSummary {
runs: number;
queued: number;
running: number;
completed: number;
partial: number;
failed: number;
restarted: number;
escalated: number;
transport_failed: number;
task_failed: number;
verifier_failed: number;
cancelled: number;
stale: number;
workers: Record<string, FleetWorkerStatus>;
}
export interface FleetTaskStatusSummary {
task_id: string;
status: "enqueued" | "leased" | "completed" | "failed" | "cancelled";
leased_to?: string | null;
attempts: number;
}
export interface FleetRunSummary {
id: string;
name: string;
status: FleetStatusSummary;
task_count: number;
worker_count: number;
tasks: FleetTaskStatusSummary[];
labels: Record<string, string>;
created_at: string;
updated_at?: string | null;
completed_at?: string | null;
}
export interface FleetRunDetail extends FleetRunSummary {
task_specs: FleetTaskSpec[];
worker_specs: FleetWorkerSpec[];
}
export interface FleetRunsResponse {
status: FleetStatusSummary;
runs: FleetRunSummary[];
}
export interface FleetTaskSpec {
id: string;
name: string;
description?: string | null;
objective?: string | null;
instructions: string;
worker?: FleetTaskWorkerProfile | null;
workspace?: FleetWorkspaceRequirements | null;
input_files?: string[];
context?: string[];
budget?: FleetTaskBudget | null;
tags?: string[];
expected_artifacts?: FleetArtifactKind[];
scorer?: Record<string, unknown> | null;
retry_policy?: Record<string, unknown> | null;
alert_policy?: FleetAlertPolicy | null;
timeout_seconds?: number | null;
metadata?: Record<string, unknown>;
}
export interface FleetTaskWorkerProfile {
role?: string | null;
tool_profile?: string | null;
tools?: string[];
capabilities?: string[];
}
export interface FleetWorkspaceRequirements {
root?: string | null;
required_files?: string[];
writable_paths?: string[];
environment?: FleetEnvironmentRequirements | null;
}
export interface FleetEnvironmentRequirements {
required?: string[];
allowlist?: string[];
}
export interface FleetTaskBudget {
max_tokens?: number | null;
max_tool_calls?: number | null;
max_seconds?: number | null;
}
export interface FleetAlertPolicy {
events?: string[];
channels?: Array<Record<string, unknown>>;
after_attempts?: number | null;
after_minutes_stale?: number | null;
}
export interface FleetWorkerSpec {
id: string;
name: string;
host: Record<string, unknown>;
labels?: Record<string, string>;
capabilities?: string[];
max_concurrent_tasks?: number | null;
}
export interface FleetArtifactRef {
kind: FleetArtifactKind;
path: string;
checksum?: string | null;
mime_type?: string | null;
size_bytes?: number | null;
}
export type FleetWorkerEventPayload =
| { state: "queued" }
| { state: "leased"; lease_expires_at?: string | null }
| { state: "starting" }
| { state: "running" }
| { state: "model_wait"; model?: string | null }
| { state: "running_tool"; tool: string; call_id?: string | null }
| { state: "heartbeat"; cpu_percent?: number | null; memory_mb?: number | null }
| ({ state: "artifact" } & FleetArtifactRef)
| { state: "completed"; exit_code?: number | null; summary?: string | null }
| { state: "failed"; reason: string; recoverable?: boolean }
| { state: "cancelled"; cancelled_by?: string | null }
| { state: "interrupted"; signal?: string | null }
| { state: "stale"; last_heartbeat_at?: string | null }
| { state: "restarted"; restart_count?: number }
| { state: "escalated"; channel: string; alert_id?: string | null };
export interface FleetWorkerEvent {
seq: number;
run_id: string;
worker_id: string;
task_id: string;
timestamp: string;
label?: string;
payload: FleetWorkerEventPayload;
extra?: Record<string, unknown>;
}
export interface FleetWorkerInspection {
worker_id: string;
status: FleetWorkerStatus;
run_id?: string | null;
task_id?: string | null;
objective?: string | null;
role?: string | null;
host?: Record<string, unknown> | null;
latest_heartbeat_at?: string | null;
latest_event?: FleetWorkerEvent | null;
artifacts: FleetArtifactRef[];
last_error?: string | null;
alert_state?: Record<string, unknown> | null;
}
export interface FleetWorkersResponse {
run_id: string;
workers: FleetWorkerInspection[];
}
export interface FleetWorkerActionResponse {
action: "interrupt" | "restart";
worker: FleetWorkerInspection;
}
export interface StopFleetRunResponse {
action: "stop";
run_id: string;
stopped: number;
status: FleetStatusSummary;
}
export interface RuntimeClientOptions {
baseUrl?: string;
token?: string;
fetch?: typeof fetch;
}
export interface FleetRunCreateSpec {
name?: string;
task_specs?: FleetTaskSpec[];
worker_specs?: FleetWorkerSpec[];
labels?: Record<string, string>;
}
export class RuntimeApiError extends Error {
status?: number;
method?: string;
path?: string;
body?: string;
}
export class RuntimeCapabilityError extends RuntimeApiError {
capability: string;
}
export class CodeWhaleRuntimeClient {
constructor(options?: RuntimeClientOptions);
createFleetRun(spec: FleetRunCreateSpec | Record<string, unknown>): Promise<unknown>;
listFleetRuns(): Promise<FleetRunsResponse>;
getFleetRun(runId: FleetRunId): Promise<FleetRunDetail>;
listFleetWorkers(runId: FleetRunId): Promise<FleetWorkersResponse>;
getFleetWorker(workerId: string): Promise<FleetWorkerInspection>;
interruptWorker(workerId: string): Promise<FleetWorkerActionResponse>;
restartWorker(workerId: string): Promise<FleetWorkerActionResponse>;
stopFleetRun(runId: FleetRunId): Promise<StopFleetRunResponse>;
fleetEvents(
runId: FleetRunId,
options?: { path?: string },
): AsyncIterable<FleetWorkerEvent>;
}
export function createRuntimeClient(options?: RuntimeClientOptions): CodeWhaleRuntimeClient;
+198
View File
@@ -0,0 +1,198 @@
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);
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@codewhale/runtime-sdk",
"version": "0.8.60",
"description": "Typed JavaScript helpers for CodeWhale Runtime API fleet endpoints.",
"license": "MIT",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"exports": {
".": {
"types": "./index.d.ts",
"default": "./index.js"
}
},
"files": [
"index.js",
"index.d.ts",
"README.md",
"package.json"
],
"scripts": {
"test": "node --test test/*.test.mjs && node --check index.js"
},
"engines": {
"node": ">=18"
},
"publishConfig": {
"access": "public"
}
}
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CodeWhaleRuntimeClient,
RuntimeApiError,
RuntimeCapabilityError,
createRuntimeClient,
} from "../index.js";
function jsonResponse(body, init = {}) {
return new Response(JSON.stringify(body), {
status: init.status ?? 200,
headers: { "content-type": "application/json", ...(init.headers ?? {}) },
});
}
function fakeFetch(responseFactory) {
const calls = [];
const fetch = async (url, init) => {
calls.push({ url: url.toString(), init });
return responseFactory(url, init, calls.length);
};
fetch.calls = calls;
return fetch;
}
test("listFleetRuns calls the Runtime API with bearer auth", async () => {
const fetch = fakeFetch(() =>
jsonResponse({
status: { runs: 1, workers: {} },
runs: [{ id: "run-1", name: "smoke", tasks: [], labels: {} }],
}),
);
const client = createRuntimeClient({
baseUrl: "http://127.0.0.1:7878",
token: "token-1",
fetch,
});
const response = await client.listFleetRuns();
assert.equal(response.runs[0].id, "run-1");
assert.equal(fetch.calls[0].url, "http://127.0.0.1:7878/v1/fleet/runs");
assert.equal(fetch.calls[0].init.method, "GET");
assert.equal(fetch.calls[0].init.headers.get("authorization"), "Bearer token-1");
});
test("worker and run actions use POST endpoints", async () => {
const fetch = fakeFetch((url) =>
jsonResponse(
url.pathname.endsWith("/stop")
? {
action: "stop",
run_id: "run-1",
stopped: 1,
status: { runs: 1, workers: {} },
}
: {
action: url.pathname.endsWith("/restart") ? "restart" : "interrupt",
worker: { worker_id: "w1", artifacts: [] },
},
),
);
const client = new CodeWhaleRuntimeClient({ fetch });
await client.interruptWorker("w1");
await client.restartWorker("w1");
await client.stopFleetRun("run-1");
assert.deepEqual(
fetch.calls.map((call) => [new URL(call.url).pathname, call.init.method]),
[
["/v1/fleet/workers/w1/interrupt", "POST"],
["/v1/fleet/workers/w1/restart", "POST"],
["/v1/fleet/runs/run-1/stop", "POST"],
],
);
});
test("unsupported fleet capabilities raise typed errors", async () => {
const fetch = fakeFetch(() => jsonResponse({ error: "not found" }, { status: 404 }));
const client = new CodeWhaleRuntimeClient({ fetch });
await assert.rejects(
() => client.createFleetRun({ name: "future" }),
(error) =>
error instanceof RuntimeCapabilityError &&
error.capability === "fleet_run_create" &&
error.status === 404,
);
await assert.rejects(
async () => {
for await (const _event of client.fleetEvents("run-1")) {
throw new Error("unexpected event");
}
},
(error) =>
error instanceof RuntimeCapabilityError &&
error.capability === "fleet_event_stream" &&
error.status === 404,
);
});
test("fleetEvents can replay JSON event fixtures when the API exposes them", async () => {
const fetch = fakeFetch(() =>
jsonResponse({
events: [
{
seq: 1,
run_id: "run-1",
worker_id: "w1",
task_id: "task-1",
timestamp: "2026-06-13T00:00:00Z",
label: "running",
payload: { state: "running" },
},
],
}),
);
const client = new CodeWhaleRuntimeClient({ fetch });
const events = [];
for await (const event of client.fleetEvents("run-1", { path: "/v1/fleet/runs/run-1/events" })) {
events.push(event);
}
assert.equal(events.length, 1);
assert.equal(events[0].payload.state, "running");
});
test("fleetEvents parses text/event-stream frames", async () => {
const encoder = new TextEncoder();
const body = new ReadableStream({
start(controller) {
controller.enqueue(
encoder.encode(
'data: {"seq":2,"run_id":"run-1","worker_id":"w1","task_id":"task-1","timestamp":"2026-06-13T00:00:01Z","label":"heartbeat","payload":{"state":"heartbeat","memory_mb":128}}\n\n',
),
);
controller.close();
},
});
const fetch = fakeFetch(
() =>
new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
}),
);
const client = new CodeWhaleRuntimeClient({ fetch });
const events = [];
for await (const event of client.fleetEvents("run-1")) {
events.push(event);
}
assert.equal(events.length, 1);
assert.equal(events[0].payload.state, "heartbeat");
assert.equal(events[0].payload.memory_mb, 128);
});
test("ordinary HTTP errors remain RuntimeApiError", async () => {
const fetch = fakeFetch(() => jsonResponse({ error: "bad" }, { status: 500 }));
const client = new CodeWhaleRuntimeClient({ fetch });
await assert.rejects(
() => client.getFleetRun("run-1"),
(error) =>
error instanceof RuntimeApiError &&
!(error instanceof RuntimeCapabilityError) &&
error.status === 500,
);
});
+66
View File
@@ -4,6 +4,11 @@
"requires": true,
"packages": {
"": {
"workspaces": [
"npm/codewhale",
"npm/deepseek-tui",
"npm/runtime-sdk"
],
"devDependencies": {
"wrangler": "^4.94.0"
}
@@ -119,6 +124,10 @@
"node": ">=16"
}
},
"node_modules/@codewhale/runtime-sdk": {
"resolved": "npm/runtime-sdk",
"link": true
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
@@ -1207,6 +1216,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/codewhale": {
"resolved": "npm/codewhale",
"link": true
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -1221,6 +1234,10 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/deepseek-tui": {
"resolved": "npm/deepseek-tui",
"link": true
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1605,6 +1622,55 @@
"@poppinss/exception": "^1.2.2",
"error-stack-parser-es": "^1.0.5"
}
},
"npm/codewhale": {
"version": "0.8.59",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Hmbown"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/hmbown"
}
],
"hasInstallScript": true,
"license": "MIT",
"bin": {
"codew": "bin/codew.js",
"codewhale": "bin/codewhale.js",
"codewhale-tui": "bin/codewhale-tui.js"
},
"engines": {
"node": ">=18"
}
},
"npm/deepseek-tui": {
"version": "0.8.49",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/Hmbown"
},
{
"type": "buymeacoffee",
"url": "https://www.buymeacoffee.com/hmbown"
}
],
"hasInstallScript": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"npm/runtime-sdk": {
"name": "@codewhale/runtime-sdk",
"version": "0.8.60",
"license": "MIT",
"engines": {
"node": ">=18"
}
}
}
}
+6
View File
@@ -1,4 +1,10 @@
{
"private": true,
"workspaces": [
"npm/codewhale",
"npm/deepseek-tui",
"npm/runtime-sdk"
],
"devDependencies": {
"wrangler": "^4.94.0"
}