| 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 |