返回 CodeWhale
api.ts
根目录 / extensions / vscode / src / api.ts
1 /**
2 * Codewhale Runtime HTTP/SSE client — the `/v1` contract documented in
3 * `docs/RUNTIME_API.md`.
4 *
5 * This module is deliberately VS Code-free so it can be unit-tested with
6 * plain node. Callers pass the base URL and token explicitly; the VS Code
7 * side of token resolution lives in `secrets.ts` / `runtime.ts`.
8 */
9 import * as http from "node:http";
10 import { SseParser, type RuntimeEvent } from "./sse";
11
12 export type { RuntimeEvent };
13
14 export interface ApiConfig {
15 baseUrl: string;
16 token?: string;
17 }
18
19 export interface ThreadRecord {
20 id: string;
21 title?: string;
22 model?: string;
23 modelProvider?: string;
24 workspace?: string;
25 mode?: string;
26 latestTurnId?: string;
27 archived: boolean;
28 updatedAt: string;
29 }
30
31 export interface ItemRecord {
32 id: string;
33 turnId?: string;
34 kind: string;
35 status?: string;
36 summary: string;
37 detail?: string;
38 metadata?: Record<string, unknown>;
39 startedAt?: string;
40 endedAt?: string;
41 }
42
43 export interface TurnRecord {
44 id: string;
45 threadId?: string;
46 status?: string;
47 effectiveModel?: string;
48 error?: string;
49 }
50
51 export interface PendingApproval {
52 id: string;
53 turnId?: string;
54 toolName: string;
55 description: string;
56 intentSummary?: string;
57 }
58
59 export interface UserInputOption {
60 label: string;
61 description?: string;
62 }
63
64 export interface UserInputQuestion {
65 header?: string;
66 id: string;
67 question: string;
68 options: UserInputOption[];
69 allowFreeText?: boolean;
70 multiSelect?: boolean;
71 }
72
73 export interface PendingUserInput {
74 id: string;
75 turnId?: string;
76 questions: UserInputQuestion[];
77 }
78
79 export interface ThreadSummary {
80 id: string;
81 title: string;
82 preview: string;
83 model: string;
84 mode: string;
85 workspace?: string;
86 branch?: string;
87 head?: string;
88 dirty: boolean;
89 archived: boolean;
90 updatedAt: string;
91 latestTurnStatus?: string;
92 }
93
94 export interface SnapshotEntry {
95 id: string;
96 label: string;
97 timestamp: number;
98 }
99
100 export interface ThreadDetail {
101 thread: ThreadRecord;
102 turns: TurnRecord[];
103 items: ItemRecord[];
104 latestSeq: number;
105 pendingApprovals: PendingApproval[];
106 pendingUserInputs: PendingUserInput[];
107 }
108
109 export interface StartTurnResult {
110 thread: ThreadRecord;
111 turn: TurnRecord;
112 }
113
114 export interface ConnectionInfo {
115 kind: "connected" | "offline" | "auth-required" | "error";
116 detail: string;
117 version?: string;
118 }
119
120 const HEALTH_TIMEOUT_MS = 2500;
121 const READ_TIMEOUT_MS = 8000;
122 const MUTATE_TIMEOUT_MS = 20000;
123
124 export async function checkConnection(config: ApiConfig): Promise<ConnectionInfo> {
125 const health = await requestJson(`${config.baseUrl}/health`, config, {
126 timeoutMs: HEALTH_TIMEOUT_MS,
127 });
128 if (health.statusCode === 0) {
129 return { kind: "offline", detail: "Runtime is not reachable." };
130 }
131 if (health.statusCode === 401) {
132 return { kind: "auth-required", detail: "Runtime requires a token." };
133 }
134 if (!isOk(health.statusCode)) {
135 return { kind: "error", detail: `Health check returned HTTP ${health.statusCode}.` };
136 }
137
138 const info = await requestJson(`${config.baseUrl}/v1/runtime/info`, config, {
139 timeoutMs: HEALTH_TIMEOUT_MS,
140 });
141 if (info.statusCode === 401) {
142 return { kind: "auth-required", detail: "Runtime info requires a token." };
143 }
144
145 // `/health` and `/v1/runtime/info` are intentionally unauthenticated, so a
146 // token-protected runtime answers both with HTTP 200. The info body carries
147 // the real signal: `auth_required`.
148 if (readBoolean(readBody(info.body).auth_required) && !config.token) {
149 return {
150 kind: "auth-required",
151 detail: "Runtime requires a bearer token. Store one with CodeWhale: Set Runtime Token.",
152 };
153 }
154
155 const version = readString(readBody(info.body).version);
156 return {
157 kind: "connected",
158 detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.",
159 version,
160 };
161 }
162
163 export async function listThreadSummaries(config: ApiConfig, limit = 20): Promise<ThreadSummary[]> {
164 const response = await requestJson(
165 `${config.baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`,
166 config,
167 { timeoutMs: READ_TIMEOUT_MS },
168 );
169 ensureOk(response, "Thread summaries");
170 return readThreadSummaries(response.body);
171 }
172
173 export async function getThreadDetail(config: ApiConfig, threadId: string): Promise<ThreadDetail> {
174 const response = await requestJson(
175 `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}`,
176 config,
177 { timeoutMs: READ_TIMEOUT_MS },
178 );
179 ensureOk(response, "Thread detail");
180 return readThreadDetail(response.body);
181 }
182
183 export async function createThread(
184 config: ApiConfig,
185 body: { workspace?: string; model?: string; mode?: string } = {},
186 ): Promise<ThreadRecord> {
187 const response = await requestJson(`${config.baseUrl}/v1/threads`, config, {
188 method: "POST",
189 body: JSON.stringify(body),
190 timeoutMs: MUTATE_TIMEOUT_MS,
191 });
192 ensureOk(response, "Create thread");
193 return readThread(response.body);
194 }
195
196 export interface StartTurnBody {
197 prompt: string;
198 operationKey?: string;
199 }
200
201 export async function startTurn(
202 config: ApiConfig,
203 threadId: string,
204 body: StartTurnBody,
205 ): Promise<StartTurnResult> {
206 const wire: Record<string, unknown> = { prompt: body.prompt };
207 if (body.operationKey) {
208 wire.operation_key = body.operationKey;
209 }
210 const response = await requestJson(
211 `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns`,
212 config,
213 { method: "POST", body: JSON.stringify(wire), timeoutMs: MUTATE_TIMEOUT_MS },
214 );
215 ensureOk(response, "Start turn");
216 const record = readBody(response.body);
217 return {
218 thread: readThread(record.thread),
219 turn: readTurn(record.turn),
220 };
221 }
222
223 export async function steerTurn(
224 config: ApiConfig,
225 threadId: string,
226 turnId: string,
227 prompt: string,
228 ): Promise<void> {
229 const response = await requestJson(
230 `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/steer`,
231 config,
232 { method: "POST", body: JSON.stringify({ prompt }), timeoutMs: MUTATE_TIMEOUT_MS },
233 );
234 ensureOk(response, "Steer");
235 }
236
237 /**
238 * Outcome of an interrupt. The runtime answers 409 when the turn is not
239 * running — that is "nothing to stop", not a failure, so it is reported as a
240 * value instead of thrown.
241 */
242 export type InterruptResult = "interrupted" | "not-running";
243
244 export async function interruptTurn(
245 config: ApiConfig,
246 threadId: string,
247 turnId: string,
248 ): Promise<InterruptResult> {
249 const response = await requestJson(
250 `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/turns/${encodeURIComponent(turnId)}/interrupt`,
251 config,
252 { method: "POST", timeoutMs: MUTATE_TIMEOUT_MS },
253 );
254 if (response.statusCode === 409) {
255 return "not-running";
256 }
257 ensureOk(response, "Interrupt");
258 return "interrupted";
259 }
260
261 export async function resolveApproval(
262 config: ApiConfig,
263 approvalId: string,
264 decision: "allow" | "deny",
265 remember = false,
266 ): Promise<void> {
267 const response = await requestJson(
268 `${config.baseUrl}/v1/approvals/${encodeURIComponent(approvalId)}`,
269 config,
270 {
271 method: "POST",
272 body: JSON.stringify({ decision, remember }),
273 timeoutMs: MUTATE_TIMEOUT_MS,
274 },
275 );
276 ensureOk(response, "Approval");
277 }
278
279 export async function answerUserInput(
280 config: ApiConfig,
281 threadId: string,
282 inputId: string,
283 answers: Array<{ id: string; label: string; value: string }>,
284 ): Promise<void> {
285 const response = await requestJson(
286 `${config.baseUrl}/v1/user-input/${encodeURIComponent(threadId)}/${encodeURIComponent(inputId)}`,
287 config,
288 { method: "POST", body: JSON.stringify({ answers }), timeoutMs: MUTATE_TIMEOUT_MS },
289 );
290 ensureOk(response, "User input");
291 }
292
293 export async function listSnapshots(config: ApiConfig, limit = 8): Promise<SnapshotEntry[]> {
294 const response = await requestJson(
295 `${config.baseUrl}/v1/snapshots?limit=${encodeURIComponent(String(limit))}`,
296 config,
297 { timeoutMs: READ_TIMEOUT_MS },
298 );
299 ensureOk(response, "Restore points");
300 return readSnapshots(response.body);
301 }
302
303 export interface EventStream {
304 readonly threadId: string;
305 onEvent: (event: RuntimeEvent) => void;
306 onError: (error: Error) => void;
307 close(): void;
308 }
309
310 /**
311 * Open the replay + live SSE stream for a thread. `sinceSeq` should be the
312 * last accepted per-thread sequence (0 for a fresh thread). The stream never
313 * reconnects on its own; callers decide the retry policy from `onError`.
314 */
315 export function openEventStream(
316 config: ApiConfig,
317 threadId: string,
318 sinceSeq: number,
319 parser = new SseParser(),
320 ): EventStream {
321 const url = `${config.baseUrl}/v1/threads/${encodeURIComponent(threadId)}/events?since_seq=${String(sinceSeq)}`;
322 const request = http.get(
323 url,
324 {
325 headers: {
326 Accept: "text/event-stream",
327 ...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
328 },
329 },
330 (response) => {
331 if (!isOk(response.statusCode ?? 0)) {
332 response.resume();
333 stream.onError(apiError(response.statusCode ?? 0, undefined, "Event stream"));
334 return;
335 }
336 response.setEncoding("utf8");
337 response.on("data", (chunk: string) => {
338 for (const event of parser.push(chunk)) {
339 stream.onEvent(event);
340 }
341 });
342 response.on("end", () => {
343 stream.onError(new Error("Event stream closed."));
344 });
345 response.on("error", (error: Error) => {
346 stream.onError(error);
347 });
348 },
349 );
350 request.on("error", (error: Error) => {
351 stream.onError(error);
352 });
353
354 const stream: EventStream = {
355 threadId,
356 onEvent: () => undefined,
357 onError: () => undefined,
358 close: () => {
359 request.destroy();
360 },
361 };
362 return stream;
363 }
364
365 export class ApiError extends Error {
366 readonly statusCode: number;
367 readonly detail?: string;
368
369 constructor(message: string, statusCode: number, detail?: string) {
370 super(detail ? `${message} ${detail}` : message);
371 this.name = "ApiError";
372 this.statusCode = statusCode;
373 this.detail = detail;
374 }
375 }
376
377 /**
378 * HTTP 409 from the runtime: the request was well-formed but the resource is
379 * already in a state that refuses it — most often "thread already has an
380 * active turn". Callers catch this to say "a turn is already running" instead
381 * of reporting a generic failure.
382 */
383 export class ConflictError extends ApiError {
384 constructor(message: string, detail?: string) {
385 super(message, 409, detail);
386 this.name = "ConflictError";
387 }
388 }
389
390 interface RequestResult {
391 statusCode: number;
392 body: unknown;
393 }
394
395 /**
396 * Success is the whole 2xx range, mirroring `response.ok` in the embedded web
397 * client (`crates/tui/src/runtime_web/app.mjs:873`). The runtime answers 201
398 * CREATED for `POST /v1/threads/{id}/turns`, so an equality check against a
399 * hand-picked list of codes rejects every real send.
400 */
401 function isOk(statusCode: number): boolean {
402 return statusCode >= 200 && statusCode < 300;
403 }
404
405 /** Build the typed error for a non-2xx status, surfacing the runtime's own message. */
406 function apiError(statusCode: number, body: unknown, label: string): ApiError {
407 const detail = readErrorDetail(body);
408 if (statusCode === 0) {
409 return new ApiError(`${label} could not reach the runtime.`, 0, detail);
410 }
411 if (statusCode === 401) {
412 return new ApiError(`${label} requires the runtime token.`, 401, detail);
413 }
414 if (statusCode === 409) {
415 return new ConflictError(`${label} conflicts with the runtime's current state.`, detail);
416 }
417 return new ApiError(`${label} returned HTTP ${statusCode}.`, statusCode, detail);
418 }
419
420 /** Throw unless the runtime answered 2xx. Every route's status check runs through here. */
421 function ensureOk(response: RequestResult, label: string): void {
422 if (!isOk(response.statusCode)) {
423 throw apiError(response.statusCode, response.body, label);
424 }
425 }
426
427 async function requestJson(
428 url: string,
429 config: ApiConfig,
430 options: { method?: string; body?: string; timeoutMs: number },
431 ): Promise<RequestResult> {
432 try {
433 return await new Promise<RequestResult>((resolve, reject) => {
434 const request = http.request(
435 url,
436 {
437 method: options.method ?? "GET",
438 timeout: options.timeoutMs,
439 headers: {
440 Accept: "application/json",
441 ...(options.body ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(options.body) } : {}),
442 ...(config.token ? { Authorization: `Bearer ${config.token}` } : {}),
443 },
444 },
445 (response) => {
446 let raw = "";
447 response.setEncoding("utf8");
448 response.on("data", (chunk: string) => {
449 raw += chunk;
450 });
451 response.on("end", () => {
452 resolve({ statusCode: response.statusCode ?? 0, body: parseJson(raw) });
453 });
454 },
455 );
456 if (options.body) {
457 request.write(options.body);
458 }
459 request.on("timeout", () => {
460 request.destroy(new Error("Runtime request timed out."));
461 });
462 request.on("error", reject);
463 request.end();
464 });
465 } catch (error: unknown) {
466 const detail = error instanceof Error ? error.message : String(error);
467 return { statusCode: 0, body: { error: detail } };
468 }
469 }
470
471 function parseJson(raw: string): unknown {
472 try {
473 return JSON.parse(raw);
474 } catch {
475 return undefined;
476 }
477 }
478
479 function readBody(body: unknown): Record<string, unknown> {
480 return body && typeof body === "object" ? (body as Record<string, unknown>) : {};
481 }
482
483 function readErrorDetail(body: unknown): string | undefined {
484 const record = readBody(body);
485 const error = record.error;
486 if (typeof error === "string") {
487 return error;
488 }
489 if (error && typeof error === "object") {
490 const message = readBody(error).message ?? readBody(error).code;
491 if (typeof message === "string") {
492 return message;
493 }
494 }
495 return undefined;
496 }
497
498 function readString(value: unknown): string | undefined {
499 return typeof value === "string" ? value : undefined;
500 }
501
502 function readNumber(value: unknown): number | undefined {
503 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
504 }
505
506 function readBoolean(value: unknown): boolean {
507 return value === true;
508 }
509
510 function readThread(value: unknown): ThreadRecord {
511 const record = readBody(value);
512 return {
513 id: readString(record.id) ?? "",
514 title: readString(record.title),
515 model: readString(record.model),
516 modelProvider: readString(record.model_provider),
517 workspace: readString(record.workspace),
518 mode: readString(record.mode),
519 latestTurnId: readString(record.latest_turn_id),
520 archived: record.archived === true,
521 updatedAt: readString(record.updated_at) ?? "",
522 };
523 }
524
525 function readTurn(value: unknown): TurnRecord {
526 const record = readBody(value);
527 return {
528 id: readString(record.id) ?? "",
529 threadId: readString(record.thread_id),
530 status: readString(record.status),
531 effectiveModel: readString(record.effective_model),
532 error: readString(record.error),
533 };
534 }
535
536 function readItem(value: unknown): ItemRecord | undefined {
537 const record = readBody(value);
538 const id = readString(record.id);
539 if (!id) {
540 return undefined;
541 }
542 return {
543 id,
544 turnId: readString(record.turn_id),
545 kind: readString(record.kind) ?? "status",
546 status: readString(record.status),
547 summary: readString(record.summary) ?? "",
548 detail: readString(record.detail),
549 metadata:
550 record.metadata && typeof record.metadata === "object"
551 ? (record.metadata as Record<string, unknown>)
552 : undefined,
553 startedAt: readString(record.started_at),
554 endedAt: readString(record.ended_at),
555 };
556 }
557
558 function readThreadDetail(value: unknown): ThreadDetail {
559 const record = readBody(value);
560 const thread = readThread(record.thread);
561 const items = Array.isArray(record.items)
562 ? record.items.flatMap((item) => {
563 const parsed = readItem(item);
564 return parsed ? [parsed] : [];
565 })
566 : [];
567 const turns = Array.isArray(record.turns)
568 ? record.turns.flatMap((turn) => {
569 const parsed = readTurn(turn);
570 return parsed.id ? [parsed] : [];
571 })
572 : [];
573
574 const pendingApprovals = Array.isArray(record.pending_approvals)
575 ? record.pending_approvals.flatMap((entry) => {
576 const approval = readBody(entry);
577 const id = readString(approval.id);
578 if (!id) {
579 return [];
580 }
581 return [
582 {
583 id,
584 turnId: readString(approval.turn_id),
585 toolName: readString(approval.tool_name) ?? "tool",
586 description: readString(approval.description) ?? "",
587 intentSummary: readString(approval.intent_summary),
588 },
589 ];
590 })
591 : [];
592
593 const pendingUserInputs = Array.isArray(record.pending_user_inputs)
594 ? record.pending_user_inputs.flatMap((entry) => {
595 const input = readBody(entry);
596 const id = readString(input.id);
597 const request = readBody(input.request);
598 const questions = Array.isArray(request.questions)
599 ? request.questions.flatMap((raw) => {
600 const question = readBody(raw);
601 const questionId = readString(question.id);
602 if (!questionId) {
603 return [];
604 }
605 return [
606 {
607 header: readString(question.header),
608 id: questionId,
609 question: readString(question.question) ?? "",
610 allowFreeText: question.allow_free_text === true,
611 multiSelect: question.multi_select === true,
612 options: Array.isArray(question.options)
613 ? question.options.flatMap((option) => {
614 const recordOption = readBody(option);
615 const label = readString(recordOption.label);
616 return label ? [{ label, description: readString(recordOption.description) }] : [];
617 })
618 : [],
619 },
620 ];
621 })
622 : [];
623 if (!id) {
624 return [];
625 }
626 return [{ id, turnId: readString(input.turn_id), questions }];
627 })
628 : [];
629
630 return {
631 thread,
632 turns,
633 items,
634 latestSeq: readNumber(record.latest_seq) ?? 0,
635 pendingApprovals,
636 pendingUserInputs,
637 };
638 }
639
640 function readThreadSummaries(value: unknown): ThreadSummary[] {
641 if (!Array.isArray(value)) {
642 return [];
643 }
644 return value.flatMap((item) => {
645 const record = readBody(item);
646 const id = readString(record.id);
647 if (!id) {
648 return [];
649 }
650 return [
651 {
652 id,
653 title: readString(record.title) ?? "New Thread",
654 preview: readString(record.preview) ?? "",
655 model: readString(record.model) ?? "unknown",
656 mode: readString(record.mode) ?? "agent",
657 workspace: readString(record.workspace),
658 branch: readString(record.branch),
659 head: readString(record.head),
660 dirty: record.dirty === true,
661 archived: record.archived === true,
662 updatedAt: readString(record.updated_at) ?? "",
663 latestTurnStatus: readString(record.latest_turn_status),
664 },
665 ];
666 });
667 }
668
669 function readSnapshots(value: unknown): SnapshotEntry[] {
670 if (!Array.isArray(value)) {
671 return [];
672 }
673 return value.flatMap((item) => {
674 const record = readBody(item);
675 const id = readString(record.id);
676 const label = readString(record.label);
677 const timestamp = readNumber(record.timestamp);
678 if (!id || !label || timestamp === undefined) {
679 return [];
680 }
681 return [{ id, label, timestamp }];
682 });
683 }
684
684 lines TYPESCRIPT