返回 CodeWhale
index.js
根目录 / npm / runtime-sdk / index.js
1 const DEFAULT_BASE_URL = "http://127.0.0.1:7878";
2
3 export class RuntimeApiError extends Error {
4 constructor(message, options = {}) {
5 super(message);
6 this.name = "RuntimeApiError";
7 this.status = options.status;
8 this.method = options.method;
9 this.path = options.path;
10 this.body = options.body;
11 }
12 }
13
14 export class RuntimeCapabilityError extends RuntimeApiError {
15 constructor(capability, message, options = {}) {
16 super(message, options);
17 this.name = "RuntimeCapabilityError";
18 this.capability = capability;
19 }
20 }
21
22 export class CodeWhaleRuntimeClient {
23 constructor(options = {}) {
24 this.baseUrl = normalizeBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
25 this.token = options.token ?? null;
26 this.fetchImpl = options.fetch ?? globalThis.fetch;
27 if (typeof this.fetchImpl !== "function") {
28 throw new TypeError("CodeWhaleRuntimeClient requires a fetch implementation");
29 }
30 }
31
32 async createFleetRun(spec) {
33 return this.#jsonRequest("/v1/fleet/runs", {
34 method: "POST",
35 body: spec,
36 capability: "fleet_run_create",
37 });
38 }
39
40 async startFleetRun(runId) {
41 return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}/start`, {
42 method: "POST",
43 capability: "fleet_run_start",
44 });
45 }
46
47 async replayFleetEvents(runId, options = {}) {
48 const path = fleetEventPath(
49 `/v1/fleet/runs/${segment(runId)}/events/replay`,
50 options,
51 );
52 return this.#jsonRequest(path, {
53 capability: "fleet_event_replay",
54 });
55 }
56
57 async listFleetRuns() {
58 return this.#jsonRequest("/v1/fleet/runs");
59 }
60
61 async getFleetRun(runId) {
62 return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}`);
63 }
64
65 async listFleetWorkers(runId) {
66 return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}/workers`);
67 }
68
69 async getFleetWorker(workerId) {
70 return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}`);
71 }
72
73 async interruptWorker(workerId) {
74 return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}/interrupt`, {
75 method: "POST",
76 });
77 }
78
79 async stopWorker(workerId) {
80 return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}/stop`, {
81 method: "POST",
82 });
83 }
84
85 async restartWorker(workerId) {
86 return this.#jsonRequest(`/v1/fleet/workers/${segment(workerId)}/restart`, {
87 method: "POST",
88 });
89 }
90
91 async stopFleetRun(runId) {
92 return this.#jsonRequest(`/v1/fleet/runs/${segment(runId)}/stop`, {
93 method: "POST",
94 });
95 }
96
97 async *fleetEvents(runId, options = {}) {
98 const path = fleetEventPath(
99 options.path ?? `/v1/fleet/runs/${segment(runId)}/events`,
100 options,
101 );
102 const response = await this.#rawRequest(path, {
103 method: "GET",
104 capability: "fleet_event_stream",
105 accept: "text/event-stream",
106 });
107 const contentType = response.headers.get("content-type") ?? "";
108 if (contentType.includes("application/json")) {
109 const payload = await response.json();
110 const events = Array.isArray(payload) ? payload : (payload.events ?? []);
111 for (const event of events) {
112 yield event;
113 }
114 return;
115 }
116 if (!response.body) {
117 throw new RuntimeApiError("Runtime API event response did not include a readable body", {
118 method: "GET",
119 path,
120 });
121 }
122 for await (const event of parseEventStream(response.body)) {
123 yield event;
124 }
125 }
126
127 async #jsonRequest(path, options = {}) {
128 const response = await this.#rawRequest(path, options);
129 if (response.status === 204) {
130 return null;
131 }
132 return response.json();
133 }
134
135 async #rawRequest(path, options = {}) {
136 const method = options.method ?? "GET";
137 const headers = new Headers(options.headers);
138 headers.set("accept", options.accept ?? "application/json");
139 if (this.token) {
140 headers.set("authorization", `Bearer ${this.token}`);
141 }
142 const init = { method, headers };
143 if (options.body !== undefined) {
144 headers.set("content-type", "application/json");
145 init.body = JSON.stringify(options.body);
146 }
147
148 const response = await this.fetchImpl(new URL(path, this.baseUrl), init);
149 if (response.ok) {
150 return response;
151 }
152
153 const body = await readErrorBody(response);
154 const errorOptions = { status: response.status, method, path, body };
155 if (options.capability && [404, 405, 501].includes(response.status)) {
156 throw new RuntimeCapabilityError(
157 options.capability,
158 `Runtime API capability '${options.capability}' is not available at ${method} ${path}`,
159 errorOptions,
160 );
161 }
162 throw new RuntimeApiError(
163 `Runtime API request failed (${response.status}) for ${method} ${path}`,
164 errorOptions,
165 );
166 }
167 }
168
169 export function createRuntimeClient(options = {}) {
170 return new CodeWhaleRuntimeClient(options);
171 }
172
173 function normalizeBaseUrl(value) {
174 return value.endsWith("/") ? value : `${value}/`;
175 }
176
177 function segment(value) {
178 if (value === null || value === undefined || String(value).trim() === "") {
179 throw new TypeError("Runtime API path segment must be a non-empty value");
180 }
181 return encodeURIComponent(String(value));
182 }
183
184 function fleetEventPath(path, options) {
185 const query = new URLSearchParams();
186 if (options.after !== undefined && options.after !== null && String(options.after) !== "") {
187 query.set("after", String(options.after));
188 }
189 if (options.limit !== undefined && options.limit !== null) {
190 query.set("limit", String(options.limit));
191 }
192 const encoded = query.toString();
193 if (!encoded) {
194 return path;
195 }
196 return `${path}${path.includes("?") ? "&" : "?"}${encoded}`;
197 }
198
199 async function readErrorBody(response) {
200 try {
201 const text = await response.text();
202 return text.length > 4096 ? `${text.slice(0, 4096)}...` : text;
203 } catch {
204 return "";
205 }
206 }
207
208 async function* parseEventStream(body) {
209 const decoder = new TextDecoder();
210 let buffer = "";
211 for await (const chunk of body) {
212 buffer += decoder.decode(chunk, { stream: true });
213 let boundary;
214 while ((boundary = eventStreamBoundary(buffer)) !== null) {
215 const frame = buffer.slice(0, boundary.index);
216 buffer = buffer.slice(boundary.index + boundary.length);
217 const event = parseSseFrame(frame);
218 if (event !== undefined) {
219 yield event;
220 }
221 }
222 }
223 buffer += decoder.decode();
224 const event = parseSseFrame(buffer);
225 if (event !== undefined) {
226 yield event;
227 }
228 }
229
230 function eventStreamBoundary(buffer) {
231 const lf = buffer.indexOf("\n\n");
232 const crlf = buffer.indexOf("\r\n\r\n");
233 if (lf < 0 && crlf < 0) {
234 return null;
235 }
236 if (crlf >= 0 && (lf < 0 || crlf < lf)) {
237 return { index: crlf, length: 4 };
238 }
239 return { index: lf, length: 2 };
240 }
241
242 function parseSseFrame(frame) {
243 const lines = frame.split(/\r?\n/);
244 const eventName = lines
245 .find((line) => line.startsWith("event:"))
246 ?.slice("event:".length)
247 .trimStart();
248 const eventId = lines
249 .find((line) => line.startsWith("id:"))
250 ?.slice("id:".length)
251 .trimStart();
252 const data = lines
253 .filter((line) => line.startsWith("data:"))
254 .map((line) => line.slice("data:".length).trimStart())
255 .join("\n");
256 if (!data || data === "[DONE]") {
257 return undefined;
258 }
259 const parsed = JSON.parse(data);
260 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
261 if (eventName && parsed.event === undefined) {
262 parsed.event = eventName;
263 }
264 if (eventId && parsed.cursor === undefined) {
265 parsed.cursor = eventId;
266 }
267 }
268 return parsed;
269 }
270
270 lines JAVASCRIPT