返回 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 /** Read the existing durable thread journal. This never starts a turn. */
128 async *threadEvents(threadId, options = {}) {
129 const query = new URLSearchParams();
130 for (const [key, value] of [["since_seq", options.sinceSeq], ["replay_limit", options.replayLimit]]) {
131 if (value === undefined) continue;
132 if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${key} must be a nonnegative safe integer`);
133 query.set(key, String(value));
134 }
135 if (options.includeProgress !== undefined && typeof options.includeProgress !== "boolean")
136 throw new TypeError("includeProgress must be a boolean");
137 if (options.includeProgress) query.set("progress", "true");
138 const path = `/v1/threads/${segment(threadId)}/events?${query}`;
139 const response = await this.#rawRequest(path, {
140 method: "GET", capability: "thread_event_stream", accept: "text/event-stream",
141 signal: options.signal, redirect: "error",
142 });
143 if (!response.body || !/^text\/event-stream(?:;|$)/i.test(response.headers.get("content-type") ?? "")) {
144 await response.body?.cancel();
145 throw new RuntimeApiError("Runtime thread response is not an event stream", { method: "GET", path });
146 }
147 if (options.includeProgress && response.headers.get("x-codewhale-event-progress") !== "1") {
148 await response.body.cancel();
149 throw new RuntimeCapabilityError("thread_event_progress", "Runtime does not support thread replay progress", { method: "GET", path, status: 501 });
150 }
151 yield* parseEventStream(response.body, { maxFrameChars: 2 * 1024 * 1024, requireBoundary: true });
152 }
153
154 async #jsonRequest(path, options = {}) {
155 const response = await this.#rawRequest(path, options);
156 if (response.status === 204) {
157 return null;
158 }
159 return response.json();
160 }
161
162 async #rawRequest(path, options = {}) {
163 const method = options.method ?? "GET";
164 const headers = new Headers(options.headers);
165 headers.set("accept", options.accept ?? "application/json");
166 if (this.token) {
167 headers.set("authorization", `Bearer ${this.token}`);
168 }
169 const init = { method, headers };
170 if (options.signal) init.signal = options.signal;
171 if (options.redirect) init.redirect = options.redirect;
172 if (options.body !== undefined) {
173 headers.set("content-type", "application/json");
174 init.body = JSON.stringify(options.body);
175 }
176
177 const response = await this.fetchImpl(new URL(path, this.baseUrl), init);
178 if (response.ok) {
179 return response;
180 }
181
182 const body = await readErrorBody(response);
183 const errorOptions = { status: response.status, method, path, body };
184 if (options.capability && [404, 405, 501].includes(response.status)) {
185 throw new RuntimeCapabilityError(
186 options.capability,
187 `Runtime API capability '${options.capability}' is not available at ${method} ${path}`,
188 errorOptions,
189 );
190 }
191 throw new RuntimeApiError(
192 `Runtime API request failed (${response.status}) for ${method} ${path}`,
193 errorOptions,
194 );
195 }
196 }
197
198 export function createRuntimeClient(options = {}) {
199 return new CodeWhaleRuntimeClient(options);
200 }
201
202 function normalizeBaseUrl(value) {
203 return value.endsWith("/") ? value : `${value}/`;
204 }
205
206 function segment(value) {
207 if (value === null || value === undefined || String(value).trim() === "") {
208 throw new TypeError("Runtime API path segment must be a non-empty value");
209 }
210 return encodeURIComponent(String(value));
211 }
212
213 function fleetEventPath(path, options) {
214 const query = new URLSearchParams();
215 if (options.after !== undefined && options.after !== null && String(options.after) !== "") {
216 query.set("after", String(options.after));
217 }
218 if (options.limit !== undefined && options.limit !== null) {
219 query.set("limit", String(options.limit));
220 }
221 const encoded = query.toString();
222 if (!encoded) {
223 return path;
224 }
225 return `${path}${path.includes("?") ? "&" : "?"}${encoded}`;
226 }
227
228 async function readErrorBody(response) {
229 try {
230 const text = await response.text();
231 return text.length > 4096 ? `${text.slice(0, 4096)}...` : text;
232 } catch {
233 return "";
234 }
235 }
236
237 async function* parseEventStream(body, { maxFrameChars = Infinity, requireBoundary = false } = {}) {
238 const decoder = new TextDecoder("utf-8", { fatal: requireBoundary });
239 let buffer = "";
240 for await (const chunk of body) {
241 buffer += decoder.decode(chunk, { stream: true });
242 let boundary;
243 while ((boundary = eventStreamBoundary(buffer)) !== null) {
244 if (boundary.index > maxFrameChars) throw new Error("Runtime event frame exceeds the size limit");
245 const frame = buffer.slice(0, boundary.index);
246 buffer = buffer.slice(boundary.index + boundary.length);
247 const event = parseSseFrame(frame);
248 if (event !== undefined) {
249 yield event;
250 }
251 }
252 if (buffer.length > maxFrameChars) throw new Error("Runtime event frame exceeds the size limit");
253 }
254 buffer += decoder.decode();
255 if (requireBoundary && buffer.trim()) throw new Error("Runtime event stream ended inside a frame");
256 const event = parseSseFrame(buffer);
257 if (event !== undefined) {
258 yield event;
259 }
260 }
261
262 function eventStreamBoundary(buffer) {
263 const lf = buffer.indexOf("\n\n");
264 const crlf = buffer.indexOf("\r\n\r\n");
265 if (lf < 0 && crlf < 0) {
266 return null;
267 }
268 if (crlf >= 0 && (lf < 0 || crlf < lf)) {
269 return { index: crlf, length: 4 };
270 }
271 return { index: lf, length: 2 };
272 }
273
274 function parseSseFrame(frame) {
275 const lines = frame.split(/\r?\n/);
276 const eventName = lines
277 .find((line) => line.startsWith("event:"))
278 ?.slice("event:".length)
279 .trimStart();
280 const eventId = lines
281 .find((line) => line.startsWith("id:"))
282 ?.slice("id:".length)
283 .trimStart();
284 const data = lines
285 .filter((line) => line.startsWith("data:"))
286 .map((line) => line.slice("data:".length).trimStart())
287 .join("\n");
288 if (!data || data === "[DONE]") {
289 return undefined;
290 }
291 const parsed = JSON.parse(data);
292 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
293 if (eventName && parsed.event === undefined) {
294 parsed.event = eventName;
295 }
296 if (eventId && parsed.cursor === undefined) {
297 parsed.cursor = eventId;
298 }
299 }
300 return parsed;
301 }
302
302 lines JAVASCRIPT