返回 CodeWhale
runtime.ts
根目录 / extensions / vscode / src / runtime.ts
1 import * as http from "node:http";
2 import * as vscode from "vscode";
3
4 export type RuntimeStateKind = "connected" | "offline" | "auth-required" | "error";
5
6 export interface RuntimeState {
7 kind: RuntimeStateKind;
8 baseUrl: string;
9 detail: string;
10 version?: string;
11 }
12
13 export interface ThreadSummary {
14 id: string;
15 title: string;
16 preview: string;
17 model: string;
18 mode: string;
19 workspace?: string;
20 branch?: string;
21 head?: string;
22 dirty: boolean;
23 archived: boolean;
24 updatedAt: string;
25 latestTurnStatus?: string;
26 }
27
28 export interface SnapshotEntry {
29 id: string;
30 label: string;
31 timestamp: number;
32 }
33
34 export interface RuntimeConfig {
35 commandPath: string;
36 host: string;
37 port: number;
38 token?: string;
39 agentViewRefreshIntervalSeconds: number;
40 }
41
42 export function readRuntimeConfig(): RuntimeConfig {
43 const config = vscode.workspace.getConfiguration("codewhale");
44 const commandPath = config.get<string>("commandPath", "codewhale").trim() || "codewhale";
45 const host = config.get<string>("runtimeHost", "127.0.0.1").trim() || "127.0.0.1";
46 const port = config.get<number>("runtimePort", 7878);
47 const token = config.get<string>("runtimeToken", "").trim();
48 const interval = config.get<number>("agentViewRefreshIntervalSeconds", 15);
49 return {
50 commandPath,
51 host,
52 port,
53 token: token.length > 0 ? token : undefined,
54 agentViewRefreshIntervalSeconds: clampRefreshInterval(interval),
55 };
56 }
57
58 export function runtimeBaseUrl(config: RuntimeConfig): string {
59 return `http://${config.host}:${config.port}`;
60 }
61
62 export async function checkRuntime(config: RuntimeConfig): Promise<RuntimeState> {
63 const baseUrl = runtimeBaseUrl(config);
64 const health = await requestJson(`${baseUrl}/health`, config.token);
65 if (health.statusCode === 0) {
66 return { kind: "offline", baseUrl, detail: "Runtime is not reachable." };
67 }
68 if (health.statusCode === 401) {
69 return { kind: "auth-required", baseUrl, detail: "Runtime requires a token." };
70 }
71 if (health.statusCode !== 200) {
72 return {
73 kind: "error",
74 baseUrl,
75 detail: `Health check returned HTTP ${health.statusCode}.`,
76 };
77 }
78
79 const info = await requestJson(`${baseUrl}/v1/runtime/info`, config.token);
80 if (info.statusCode === 401) {
81 return { kind: "auth-required", baseUrl, detail: "Runtime info requires a token." };
82 }
83
84 const version = readVersion(info.body);
85 return {
86 kind: "connected",
87 baseUrl,
88 detail: version ? `Connected to CodeWhale ${version}.` : "Connected to CodeWhale runtime.",
89 version,
90 };
91 }
92
93 export async function listThreadSummaries(
94 config: RuntimeConfig,
95 limit = 8,
96 ): Promise<ThreadSummary[]> {
97 const baseUrl = runtimeBaseUrl(config);
98 const response = await requestJson(
99 `${baseUrl}/v1/threads/summary?limit=${encodeURIComponent(String(limit))}`,
100 config.token,
101 );
102
103 if (response.statusCode === 401) {
104 throw new Error("Thread summaries require the runtime bearer token.");
105 }
106 if (response.statusCode !== 200) {
107 throw new Error(`Thread summary returned HTTP ${response.statusCode}.`);
108 }
109
110 return readThreadSummaries(response.body);
111 }
112
113 export async function listSnapshots(config: RuntimeConfig, limit = 8): Promise<SnapshotEntry[]> {
114 const baseUrl = runtimeBaseUrl(config);
115 const response = await requestJson(
116 `${baseUrl}/v1/snapshots?limit=${encodeURIComponent(String(limit))}`,
117 config.token,
118 );
119
120 if (response.statusCode === 401) {
121 throw new Error("Restore points require the runtime bearer token.");
122 }
123 if (response.statusCode !== 200) {
124 throw new Error(`Restore points returned HTTP ${response.statusCode}.`);
125 }
126
127 return readSnapshots(response.body);
128 }
129
130 export function startRuntimeTerminal(config: RuntimeConfig): vscode.Terminal {
131 const terminal = vscode.window.createTerminal("CodeWhale Runtime");
132 const args = [
133 "serve",
134 "--http",
135 "--host",
136 shellQuote(config.host),
137 "--port",
138 String(config.port),
139 ];
140 if (config.token) {
141 args.push("--auth-token", shellQuote(config.token));
142 }
143 terminal.sendText(`${shellQuote(config.commandPath)} ${args.join(" ")}`);
144 terminal.show();
145 return terminal;
146 }
147
148 export function openCodeWhaleTerminal(config: RuntimeConfig): vscode.Terminal {
149 const terminal = vscode.window.createTerminal("CodeWhale");
150 terminal.sendText(shellQuote(config.commandPath));
151 terminal.show();
152 return terminal;
153 }
154
155 async function requestJson(
156 url: string,
157 token: string | undefined,
158 ): Promise<{ statusCode: number; body: unknown }> {
159 try {
160 return await new Promise<{ statusCode: number; body: unknown }>((resolve, reject) => {
161 const request = http.get(
162 url,
163 {
164 timeout: 2500,
165 headers: token ? { Authorization: `Bearer ${token}` } : undefined,
166 },
167 (response) => {
168 let body = "";
169 response.setEncoding("utf8");
170 response.on("data", (chunk: string) => {
171 body += chunk;
172 });
173 response.on("end", () => {
174 resolve({
175 statusCode: response.statusCode ?? 0,
176 body: parseJson(body),
177 });
178 });
179 },
180 );
181
182 request.on("timeout", () => {
183 request.destroy(new Error("Runtime check timed out."));
184 });
185 request.on("error", reject);
186 });
187 } catch (error: unknown) {
188 const detail = error instanceof Error ? error.message : String(error);
189 return { statusCode: 0, body: { error: detail } };
190 }
191 }
192
193 function parseJson(raw: string): unknown {
194 try {
195 return JSON.parse(raw);
196 } catch {
197 return undefined;
198 }
199 }
200
201 function readVersion(value: unknown): string | undefined {
202 if (!value || typeof value !== "object") {
203 return undefined;
204 }
205 const version = (value as { version?: unknown }).version;
206 return typeof version === "string" ? version : undefined;
207 }
208
209 function readThreadSummaries(value: unknown): ThreadSummary[] {
210 if (!Array.isArray(value)) {
211 return [];
212 }
213
214 return value.flatMap((item) => {
215 if (!item || typeof item !== "object") {
216 return [];
217 }
218 const record = item as Record<string, unknown>;
219 const id = readString(record.id);
220 if (!id) {
221 return [];
222 }
223
224 return [
225 {
226 id,
227 title: readString(record.title) ?? "New Thread",
228 preview: readString(record.preview) ?? "",
229 model: readString(record.model) ?? "unknown",
230 mode: readString(record.mode) ?? "agent",
231 workspace: readString(record.workspace),
232 branch: readString(record.branch),
233 head: readString(record.head),
234 dirty: readBoolean(record.dirty),
235 archived: record.archived === true,
236 updatedAt: readString(record.updated_at) ?? "",
237 latestTurnStatus: readString(record.latest_turn_status),
238 },
239 ];
240 });
241 }
242
243 function readSnapshots(value: unknown): SnapshotEntry[] {
244 if (!Array.isArray(value)) {
245 return [];
246 }
247
248 return value.flatMap((item) => {
249 if (!item || typeof item !== "object") {
250 return [];
251 }
252 const record = item as Record<string, unknown>;
253 const id = readString(record.id);
254 const label = readString(record.label);
255 const timestamp = readNumber(record.timestamp);
256 if (!id || !label || timestamp === undefined) {
257 return [];
258 }
259
260 return [{ id, label, timestamp }];
261 });
262 }
263
264 function readString(value: unknown): string | undefined {
265 return typeof value === "string" ? value : undefined;
266 }
267
268 function readNumber(value: unknown): number | undefined {
269 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
270 }
271
272 function readBoolean(value: unknown): boolean {
273 return value === true;
274 }
275
276 function clampRefreshInterval(value: number): number {
277 if (!Number.isFinite(value)) {
278 return 15;
279 }
280 return Math.max(0, Math.min(300, Math.floor(value)));
281 }
282
283 function shellQuote(value: string): string {
284 if (/^[A-Za-z0-9_./:=+-]+$/.test(value)) {
285 return value;
286 }
287 return `'${value.replace(/'/g, "'\\''")}'`;
288 }
289
289 lines TYPESCRIPT