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