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:
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
Vendored
+245
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Generated
+66
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"npm/codewhale",
|
||||
"npm/deepseek-tui",
|
||||
"npm/runtime-sdk"
|
||||
],
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.94.0"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user