postgresai 0.16.0-dev.1 → 0.16.0-dev.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/joe.ts ADDED
@@ -0,0 +1,703 @@
1
+ import {
2
+ DEFAULT_HTTP_REQUEST_TIMEOUT_MS,
3
+ HttpRequestTimeoutError,
4
+ HttpStatusError,
5
+ formatHttpError,
6
+ isRetryableHttpStatus,
7
+ maskSecret,
8
+ normalizeBaseUrl,
9
+ describeFetchError,
10
+ isFetchTimeout,
11
+ redactSecretsForLog,
12
+ requestTimeoutSignal,
13
+ } from "./util";
14
+
15
+ /**
16
+ * Joe API v2 client (`postgres-ai` CLI surface) — synchronous contract.
17
+ *
18
+ * Every Joe verb is a thin raw-text builder over the platform rpc
19
+ * `v1.joe_command_run(instance_id, command)`: the command text is sent RAW
20
+ * (exactly what a console user could type at Joe — `plan select …`,
21
+ * `exec create index …`, `\d users`), Joe dispatches the verb itself, and the
22
+ * CLI polls `v1.joe_command_output(command_id)` until the status is terminal
23
+ * (`ok`/`error`). No queue, no session store, no idempotency keys — a fresh
24
+ * Joe session per run (Issue #438, supersedes the async !346 surface).
25
+ */
26
+
27
+ /** The Joe verb set (mirrors Joe's own dispatcher; `describe` = the \d family). */
28
+ export const JOE_COMMANDS = [
29
+ "plan",
30
+ "explain",
31
+ "exec",
32
+ "hypo",
33
+ "activity",
34
+ "terminate",
35
+ "reset",
36
+ "describe",
37
+ ] as const;
38
+
39
+ export type JoeCommand = (typeof JOE_COMMANDS)[number];
40
+
41
+ /** Output lifecycle: `pending` while Joe has not posted, then `ok`/`error`. */
42
+ export type JoeOutputStatus = "pending" | "ok" | "error";
43
+
44
+ /** Default one-shot poll budget (≤ 25 s, then resume by command id). */
45
+ export const DEFAULT_BUDGET_MS = 25_000;
46
+ const DEFAULT_POLL_INTERVAL_MS = 800;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Response shapes (the joe_command_output contract — mocked in tests)
50
+ // ---------------------------------------------------------------------------
51
+
52
+ /**
53
+ * The FULL raw result row `v1.joe_command_output` returns once Joe has posted.
54
+ * While `status` is `pending` only `command_id`/`status`/`created_at` are
55
+ * present. `plan_json` and `plan_execution_json` arrive structured — the rpc
56
+ * unwraps both from their stored jsonb-string form.
57
+ */
58
+ export interface JoeCommandOutput {
59
+ command_id: string;
60
+ status: JoeOutputStatus;
61
+ created_at?: string | null;
62
+ command?: string | null;
63
+ query?: string | null;
64
+ queryid?: string | null;
65
+ response?: string | null;
66
+ plan_text?: string | null;
67
+ plan_json?: unknown;
68
+ plan_execution_text?: string | null;
69
+ plan_execution_json?: unknown;
70
+ stats?: string | null;
71
+ recommendations?: string | null;
72
+ error?: string | null;
73
+ }
74
+
75
+ export interface ProjectListItem {
76
+ project_id: number | string;
77
+ alias: string | null;
78
+ name: string | null;
79
+ /** Whether the project's single Joe instance is ready for Joe API v2. */
80
+ joe_ready: boolean;
81
+ /** Whether the project's DBLab tunnel is connected. */
82
+ tunnel: boolean;
83
+ /** The project's active JOE instance id — the `joe_command_run` target. */
84
+ instance_id: number | string | null;
85
+ /** The project's active DBLAB instance id (not used by the Joe verbs). */
86
+ dblab_instance_id: number | string | null;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Low-level rpc caller
91
+ // ---------------------------------------------------------------------------
92
+
93
+ interface RpcCallParams {
94
+ apiKey: string;
95
+ apiBaseUrl: string;
96
+ fn: string;
97
+ body: Record<string, unknown>;
98
+ operation: string;
99
+ debug?: boolean;
100
+ timeoutMs?: number;
101
+ }
102
+
103
+ async function callRpc<T>(params: RpcCallParams): Promise<T> {
104
+ const { apiKey, apiBaseUrl, fn, body, operation, debug } = params;
105
+ if (!apiKey) {
106
+ throw new Error("API key is required");
107
+ }
108
+
109
+ const base = normalizeBaseUrl(apiBaseUrl);
110
+ const url = new URL(`${base}/rpc/${fn}`);
111
+ const payload = JSON.stringify(body);
112
+
113
+ const headers: Record<string, string> = {
114
+ "access-token": apiKey,
115
+ "Content-Type": "application/json",
116
+ "Connection": "close",
117
+ };
118
+
119
+ if (debug) {
120
+ const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
121
+ console.error(`Debug: POST URL: ${url.toString()}`);
122
+ console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
123
+ // Redact credential-shaped fields before logging (mirrors the access-token
124
+ // header masking above).
125
+ console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
126
+ }
127
+
128
+ let response: Response;
129
+ const requestTimeout = requestTimeoutSignal(params.timeoutMs);
130
+ try {
131
+ response = await fetch(url.toString(), {
132
+ method: "POST",
133
+ headers,
134
+ body: payload,
135
+ signal: requestTimeout.signal,
136
+ });
137
+ } catch (err) {
138
+ if (isFetchTimeout(err)) {
139
+ throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
140
+ }
141
+ // A transport failure (connection refused, DNS, TLS, bad host/port) never
142
+ // reaches `response.ok`; undici throws with the real reason in `err.cause`.
143
+ // Surface it — a bare "fetch failed" hides which URL/why. See util.describeFetchError.
144
+ throw new Error(describeFetchError(operation, base, err));
145
+ }
146
+
147
+ const text = await response.text();
148
+
149
+ if (debug) {
150
+ console.error(`Debug: Response status: ${response.status}`);
151
+ console.error(`Debug: Response body: ${redactSecretsForLog(text)}`);
152
+ }
153
+
154
+ if (!response.ok) {
155
+ // PostgREST maps a custom `PTxyz` sqlstate to HTTP status `xyz`, so PT403 →
156
+ // HTTP 403, PT404 → 404, etc. The RPC's user-facing message may ride in the
157
+ // HTTP reason phrase (statusText) or the JSON body — pass both through.
158
+ // The status rides on the Error so the poll loop can classify retryability.
159
+ throw new HttpStatusError(
160
+ formatHttpError(operation, response.status, text, response.statusText),
161
+ response.status
162
+ );
163
+ }
164
+
165
+ try {
166
+ return JSON.parse(text) as T;
167
+ } catch {
168
+ // Non-JSON body — redact before embedding: this Error reaches CLI stderr
169
+ // and must not bypass the debug-log redaction.
170
+ throw new Error(`${operation}: failed to parse response: ${redactSecretsForLog(text)}`);
171
+ }
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Individual rpc client functions
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export interface StartCommandParams {
179
+ apiKey: string;
180
+ apiBaseUrl: string;
181
+ /** The project's Joe instance id (resolve via {@link resolveJoeInstanceId}). */
182
+ instanceId: number | string;
183
+ /** The RAW command text Joe dispatches (e.g. `plan select 1`, `\d users`). */
184
+ command: string;
185
+ debug?: boolean;
186
+ timeoutMs?: number;
187
+ }
188
+
189
+ /**
190
+ * Start a Joe command (`v1.joe_command_run`); returns the command id.
191
+ * The rpc returns the id as a JSON string and it stays a string end-to-end —
192
+ * a bigint id would lose precision beyond 2^53 as a JS number.
193
+ */
194
+ export async function startCommand(params: StartCommandParams): Promise<string> {
195
+ const { apiKey, apiBaseUrl, instanceId, command, debug, timeoutMs } = params;
196
+ if (!String(command ?? "").trim()) {
197
+ throw new Error("command text is required");
198
+ }
199
+ const commandId = await callRpc<unknown>({
200
+ apiKey,
201
+ apiBaseUrl,
202
+ fn: "joe_command_run",
203
+ body: { instance_id: instanceId, command },
204
+ operation: "Failed to run Joe command",
205
+ debug,
206
+ timeoutMs,
207
+ });
208
+ if (typeof commandId !== "string" || !/^[0-9]+$/.test(commandId)) {
209
+ throw new Error(
210
+ `Failed to run Joe command: expected a command id string, got: ${redactSecretsForLog(JSON.stringify(commandId))}`
211
+ );
212
+ }
213
+ return commandId;
214
+ }
215
+
216
+ export interface CommandOutputParams {
217
+ apiKey: string;
218
+ apiBaseUrl: string;
219
+ commandId: string;
220
+ debug?: boolean;
221
+ timeoutMs?: number;
222
+ }
223
+
224
+ /**
225
+ * Poll a command's output (`v1.joe_command_output`) — returns the status AND
226
+ * the full result body in one call (`pending` until Joe posts the result).
227
+ */
228
+ export async function getCommandOutput(params: CommandOutputParams): Promise<JoeCommandOutput> {
229
+ const { apiKey, apiBaseUrl, commandId, debug, timeoutMs } = params;
230
+ if (!commandId) {
231
+ throw new Error("commandId is required");
232
+ }
233
+ return callRpc<JoeCommandOutput>({
234
+ apiKey,
235
+ apiBaseUrl,
236
+ fn: "joe_command_output",
237
+ body: { command_id: commandId },
238
+ operation: "Failed to fetch command output",
239
+ debug,
240
+ timeoutMs,
241
+ });
242
+ }
243
+
244
+ export interface ListProjectsParams {
245
+ apiKey: string;
246
+ apiBaseUrl: string;
247
+ orgId?: number;
248
+ debug?: boolean;
249
+ }
250
+
251
+ interface RawProjectRow {
252
+ project_id?: number | string;
253
+ alias?: string | null;
254
+ name?: string | null;
255
+ joe_ready?: boolean;
256
+ tunnel?: boolean;
257
+ instance_id?: number | string | null;
258
+ dblab_instance_id?: number | string | null;
259
+ }
260
+
261
+ function preserveIntegerId(value: number | string): number | string {
262
+ if (typeof value === "number") return value;
263
+ const parsed = Number(value);
264
+ return Number.isSafeInteger(parsed) ? parsed : value;
265
+ }
266
+
267
+ function normalizeProjectRow(row: RawProjectRow): ProjectListItem {
268
+ return {
269
+ project_id: preserveIntegerId(row.project_id ?? 0),
270
+ alias: row.alias ?? null,
271
+ name: row.name ?? null,
272
+ joe_ready: Boolean(row.joe_ready ?? false),
273
+ tunnel: Boolean(row.tunnel ?? false),
274
+ instance_id: row.instance_id == null ? null : preserveIntegerId(row.instance_id),
275
+ dblab_instance_id: row.dblab_instance_id == null ? null : preserveIntegerId(row.dblab_instance_id),
276
+ };
277
+ }
278
+
279
+ /**
280
+ * List the org's projects (org-level discovery — NOT a Joe endpoint).
281
+ * Surfaces the per-project `joe_ready` + `tunnel` state and the Joe
282
+ * `instance_id` the run rpc keys on.
283
+ */
284
+ export async function listProjects(params: ListProjectsParams): Promise<ProjectListItem[]> {
285
+ const { apiKey, apiBaseUrl, orgId, debug } = params;
286
+ const body: Record<string, unknown> = {};
287
+ if (typeof orgId === "number") {
288
+ body.org_id = orgId;
289
+ }
290
+ const rows = await callRpc<RawProjectRow[]>({
291
+ apiKey,
292
+ apiBaseUrl,
293
+ fn: "projects_list",
294
+ body,
295
+ operation: "Failed to list projects",
296
+ debug,
297
+ });
298
+ if (!Array.isArray(rows)) {
299
+ return [];
300
+ }
301
+ return rows.map(normalizeProjectRow);
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Project id-or-alias → Joe instance resolution
306
+ // ---------------------------------------------------------------------------
307
+
308
+ /** A bare numeric `--project` value is a project id; anything else is an alias/name. */
309
+ export function isNumericProjectRef(ref: string): boolean {
310
+ return /^[0-9]+$/.test(ref.trim());
311
+ }
312
+
313
+ export interface ResolveInstanceParams {
314
+ apiKey: string;
315
+ apiBaseUrl: string;
316
+ project: string;
317
+ orgId?: number;
318
+ debug?: boolean;
319
+ }
320
+
321
+ /**
322
+ * Resolve `--project <id|alias>` to the project's Joe `instance_id` (what
323
+ * `joe_command_run` keys on). Unlike a pure project-id resolver, a numeric ref
324
+ * still needs the projects listing — the instance id lives there. Accepts a
325
+ * numeric project id, or an alias/name (case-insensitive).
326
+ */
327
+ export async function resolveJoeInstanceId(params: ResolveInstanceParams): Promise<number | string> {
328
+ const ref = String(params.project ?? "").trim();
329
+ if (!ref) {
330
+ throw new Error("project is required (--project <id|alias>)");
331
+ }
332
+ const projects = await listProjects({
333
+ apiKey: params.apiKey,
334
+ apiBaseUrl: params.apiBaseUrl,
335
+ orgId: params.orgId,
336
+ debug: params.debug,
337
+ });
338
+ const needle = ref.toLowerCase();
339
+ const match = isNumericProjectRef(ref)
340
+ ? projects.find((p) => String(p.project_id) === String(preserveIntegerId(ref)))
341
+ : projects.find(
342
+ (p) =>
343
+ (p.alias !== null && p.alias.toLowerCase() === needle) ||
344
+ (p.name !== null && p.name.toLowerCase() === needle)
345
+ );
346
+ if (!match) {
347
+ throw new Error(
348
+ `Project not found for id/alias/name '${ref}'. Run 'pgai projects' to see available projects.`
349
+ );
350
+ }
351
+ if (match.instance_id == null) {
352
+ throw new Error(
353
+ `Project '${ref}' has no Joe instance. Run 'pgai projects' to see which projects have Joe ready.`
354
+ );
355
+ }
356
+ return match.instance_id;
357
+ }
358
+
359
+ // ---------------------------------------------------------------------------
360
+ // Raw command text builders (what Joe's /webui/command dispatches)
361
+ // ---------------------------------------------------------------------------
362
+
363
+ /** The \d-family variants Joe's psql allowlist accepts (`describe --variant`). */
364
+ export const DESCRIBE_VARIANTS = [
365
+ "\\d",
366
+ "\\d+",
367
+ "\\dt",
368
+ "\\dt+",
369
+ "\\di",
370
+ "\\di+",
371
+ "\\l",
372
+ "\\l+",
373
+ "\\dv",
374
+ "\\dv+",
375
+ "\\dm",
376
+ "\\dm+",
377
+ ] as const;
378
+
379
+ export interface JoeVerbInput {
380
+ /** The verb's positional payload: SQL, hypo tail, pid, or object name. */
381
+ arg?: string | null;
382
+ /** describe only: the \d-family variant (default `\d`). */
383
+ variant?: string | null;
384
+ }
385
+
386
+ /**
387
+ * Build the RAW command text for a verb — exactly what a console user could
388
+ * type at Joe. The server adds no prefix and does no verb inspection, so this
389
+ * string is the whole contract (`plan <sql>`, `terminate <pid>`, `\d+ users`).
390
+ */
391
+ export function buildJoeCommandText(command: JoeCommand, input: JoeVerbInput = {}): string {
392
+ const arg = String(input.arg ?? "").trim();
393
+ switch (command) {
394
+ case "plan":
395
+ case "explain":
396
+ case "exec":
397
+ case "hypo": {
398
+ if (!arg) {
399
+ throw new Error(`${command} requires an argument`);
400
+ }
401
+ return `${command} ${arg}`;
402
+ }
403
+ case "activity":
404
+ case "reset":
405
+ return command;
406
+ case "terminate": {
407
+ // A pid must be a bare positive integer — parseInt() would silently
408
+ // accept "12x"/"−5"/"1.5" and terminate the WRONG backend.
409
+ if (!/^[1-9][0-9]*$/.test(arg)) {
410
+ throw new Error("pid must be a positive integer");
411
+ }
412
+ return `terminate ${arg}`;
413
+ }
414
+ case "describe": {
415
+ if (!arg) {
416
+ throw new Error("describe requires an object name");
417
+ }
418
+ const variant = String(input.variant ?? "\\d").trim();
419
+ if (!(DESCRIBE_VARIANTS as readonly string[]).includes(variant)) {
420
+ throw new Error(
421
+ `Unsupported describe variant '${variant}'. Supported: ${DESCRIBE_VARIANTS.join(" ")}`
422
+ );
423
+ }
424
+ return `${variant} ${arg}`;
425
+ }
426
+ }
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Run-then-poll one-shot
431
+ // ---------------------------------------------------------------------------
432
+
433
+ export interface RunCommandParams {
434
+ apiKey: string;
435
+ apiBaseUrl: string;
436
+ instanceId: number | string;
437
+ /** The RAW command text (see {@link buildJoeCommandText}). */
438
+ command: string;
439
+ budgetMs?: number;
440
+ pollIntervalMs?: number;
441
+ debug?: boolean;
442
+ /** Injectable clock/sleep for deterministic tests. */
443
+ now?: () => number;
444
+ sleep?: (ms: number) => Promise<void>;
445
+ }
446
+
447
+ export interface RunCommandOutcome {
448
+ commandId: string;
449
+ status: JoeOutputStatus;
450
+ /** Populated once the command reaches a terminal state (ok/error). */
451
+ output: JoeCommandOutput | null;
452
+ /** True when the ≤ budget one-shot expired before a terminal state — resume by id. */
453
+ budgetExpired: boolean;
454
+ }
455
+
456
+ const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
457
+
458
+ /**
459
+ * Run a raw command then poll `joe_command_output` within the one-shot budget.
460
+ * On a terminal state returns the full output; on budget expiry returns a
461
+ * resume handle (`budgetExpired: true`) so the caller can
462
+ * `pgai joe result <command_id>` later.
463
+ */
464
+ export async function runCommand(params: RunCommandParams): Promise<RunCommandOutcome> {
465
+ const { apiKey, apiBaseUrl, instanceId, command, debug } = params;
466
+ // Defensive: only a finite budget is honored. NaN survives `??` (it is
467
+ // neither null nor undefined) and would make `deadline` NaN — `now() >= NaN`
468
+ // is always false, i.e. an UNBOUNDED poll loop.
469
+ const budgetMs =
470
+ typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0
471
+ ? params.budgetMs
472
+ : DEFAULT_BUDGET_MS;
473
+ const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
474
+ const now = params.now ?? Date.now;
475
+ const sleep = params.sleep ?? defaultSleep;
476
+
477
+ const commandId = await startCommand({ apiKey, apiBaseUrl, instanceId, command, debug });
478
+
479
+ const deadline = now() + budgetMs;
480
+ let status: JoeOutputStatus = "pending";
481
+ const remainingRequestMs = (): number =>
482
+ Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS, deadline - now()));
483
+
484
+ // A zero/tiny budget may already be exhausted by the run round-trip. Do not
485
+ // start an output request with a nominal 1ms timeout; return the valid
486
+ // command handle immediately so the caller can resume deterministically.
487
+ if (now() >= deadline) {
488
+ return { commandId, status, output: null, budgetExpired: true };
489
+ }
490
+
491
+ // Poll the output until terminal or the one-shot budget is exhausted.
492
+ for (;;) {
493
+ let output: JoeCommandOutput;
494
+ try {
495
+ output = await getCommandOutput({
496
+ apiKey,
497
+ apiBaseUrl,
498
+ commandId,
499
+ debug,
500
+ timeoutMs: remainingRequestMs(),
501
+ });
502
+ } catch (err) {
503
+ if (err instanceof HttpRequestTimeoutError) {
504
+ return { commandId, status, output: null, budgetExpired: true };
505
+ }
506
+ if (err instanceof HttpStatusError && isRetryableHttpStatus(err.status)) {
507
+ // Transient output failure (5xx proxy hiccup / Joe pod restart, or a
508
+ // 429 rate limit) — the run rpc already returned a VALID command id,
509
+ // so never throw it away: keep polling within the budget, then hand
510
+ // back the resume handle (`pgai joe result <id>`) instead of failing.
511
+ if (now() >= deadline) {
512
+ return { commandId, status, output: null, budgetExpired: true };
513
+ }
514
+ await sleep(pollIntervalMs);
515
+ continue;
516
+ }
517
+ // Terminal (PT400/PT401/PT403/PT404 and other non-retryable errors):
518
+ // abort loudly — polling on cannot succeed.
519
+ throw err;
520
+ }
521
+ status = output.status;
522
+ if (status === "ok" || status === "error") {
523
+ return { commandId, status, output, budgetExpired: false };
524
+ }
525
+ if (now() >= deadline) {
526
+ return { commandId, status, output: null, budgetExpired: true };
527
+ }
528
+ await sleep(pollIntervalMs);
529
+ }
530
+ }
531
+
532
+ // ---------------------------------------------------------------------------
533
+ // High-level orchestrator (the CLI verb surface)
534
+ // ---------------------------------------------------------------------------
535
+
536
+ export interface ExecuteJoeParams {
537
+ apiKey: string;
538
+ apiBaseUrl: string;
539
+ command: JoeCommand;
540
+ /** Raw `--project <id|alias>` value (resolved via `projects_list`). */
541
+ project?: string;
542
+ /**
543
+ * Direct `--instance-id` value — skips project resolution entirely (the v1
544
+ * path while `projects_list` is not deployed). Kept a string end-to-end so
545
+ * a 64-bit id never rounds through a JS number; PostgREST casts it to the
546
+ * rpc's bigint param. Wins over `project` when both are given.
547
+ */
548
+ instanceId?: number | string;
549
+ input?: JoeVerbInput;
550
+ orgId?: number;
551
+ budgetMs?: number;
552
+ pollIntervalMs?: number;
553
+ debug?: boolean;
554
+ now?: () => number;
555
+ sleep?: (ms: number) => Promise<void>;
556
+ }
557
+
558
+ export interface ExecuteJoeOutcome extends RunCommandOutcome {
559
+ command: JoeCommand;
560
+ instanceId: number | string;
561
+ /** The raw text that went on the wire (debugging/tests). */
562
+ commandText: string;
563
+ }
564
+
565
+ /**
566
+ * Build the raw command text from the verb, target the Joe instance (directly
567
+ * via `instanceId`, or by resolving the project id-or-alias), and run the
568
+ * one-shot. The text is built FIRST so a bad verb argument (e.g. a garbage
569
+ * pid) fails before any network call.
570
+ */
571
+ export async function executeJoeCommand(params: ExecuteJoeParams): Promise<ExecuteJoeOutcome> {
572
+ const commandText = buildJoeCommandText(params.command, params.input);
573
+
574
+ let instanceId: number | string;
575
+ const directRef = String(params.instanceId ?? "").trim();
576
+ if (directRef) {
577
+ if (!/^[0-9]+$/.test(directRef)) {
578
+ throw new Error("instanceId must be a numeric Joe instance id");
579
+ }
580
+ instanceId = directRef;
581
+ } else if (String(params.project ?? "").trim()) {
582
+ instanceId = await resolveJoeInstanceId({
583
+ apiKey: params.apiKey,
584
+ apiBaseUrl: params.apiBaseUrl,
585
+ project: String(params.project),
586
+ orgId: params.orgId,
587
+ debug: params.debug,
588
+ });
589
+ } else {
590
+ throw new Error("either instanceId or project is required");
591
+ }
592
+
593
+ const outcome = await runCommand({
594
+ apiKey: params.apiKey,
595
+ apiBaseUrl: params.apiBaseUrl,
596
+ instanceId,
597
+ command: commandText,
598
+ budgetMs: params.budgetMs,
599
+ pollIntervalMs: params.pollIntervalMs,
600
+ debug: params.debug,
601
+ now: params.now,
602
+ sleep: params.sleep,
603
+ });
604
+
605
+ return { ...outcome, command: params.command, instanceId, commandText };
606
+ }
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // Presentation helpers (pure — unit tested)
610
+ // ---------------------------------------------------------------------------
611
+
612
+ interface PlanNode {
613
+ "Node Type"?: string;
614
+ "Relation Name"?: string;
615
+ Plans?: PlanNode[];
616
+ [key: string]: unknown;
617
+ }
618
+
619
+ /**
620
+ * Lightweight CLIENT-SIDE plan flagging: flag obvious issues (e.g. a Seq Scan)
621
+ * from the returned structured plan_json itself.
622
+ */
623
+ export function clientSidePlanFlags(planJson: unknown): string[] {
624
+ const flags: string[] = [];
625
+ const walk = (node: PlanNode | undefined): void => {
626
+ if (!node || typeof node !== "object") {
627
+ return;
628
+ }
629
+ const nodeType = node["Node Type"];
630
+ if (nodeType === "Seq Scan") {
631
+ const rel = node["Relation Name"];
632
+ flags.push(
633
+ `client-side: Seq Scan${rel ? ` on ${rel}` : ""} — no index serves this predicate; consider adding one.`
634
+ );
635
+ }
636
+ if (Array.isArray(node.Plans)) {
637
+ for (const child of node.Plans) {
638
+ walk(child);
639
+ }
640
+ }
641
+ };
642
+ if (Array.isArray(planJson)) {
643
+ // EXPLAIN (format json) returns an array: [{ "Plan": { … } }].
644
+ for (const entry of planJson) {
645
+ if (entry && typeof entry === "object") {
646
+ walk((entry as { Plan?: PlanNode }).Plan ?? (entry as PlanNode));
647
+ }
648
+ }
649
+ return flags;
650
+ }
651
+ if (planJson && typeof planJson === "object") {
652
+ const root = planJson as { Plan?: PlanNode };
653
+ walk(root.Plan ?? (planJson as PlanNode));
654
+ }
655
+ return flags;
656
+ }
657
+
658
+ /**
659
+ * Format a terminal command output as human-readable text (non-JSON mode).
660
+ * The sync contract returns one uniform row for every verb, so this prints
661
+ * whichever sections are present rather than switching per command.
662
+ */
663
+ export function formatJoeOutput(output: JoeCommandOutput): string {
664
+ const lines: string[] = [];
665
+ const section = (value: string | null | undefined, label?: string): void => {
666
+ if (value == null || value.trim() === "") return;
667
+ if (lines.length > 0) lines.push("");
668
+ if (label) lines.push(`${label}:`);
669
+ lines.push(value);
670
+ };
671
+ section(output.response);
672
+ section(output.plan_text, "plan");
673
+ const flags = clientSidePlanFlags(output.plan_json).map((flag) => `⚑ ${flag}`);
674
+ if (flags.length > 0) {
675
+ lines.push(...flags);
676
+ }
677
+ section(output.plan_execution_text, "execution plan (EXPLAIN ANALYZE)");
678
+ section(output.stats, "stats");
679
+ section(output.recommendations, "recommendations");
680
+ if (output.queryid) {
681
+ if (lines.length > 0) lines.push("");
682
+ lines.push(`(queryid ${output.queryid})`);
683
+ }
684
+ return lines.join("\n");
685
+ }
686
+
687
+ /** Render `pgai projects` as a fixed-width table. */
688
+ export function formatProjectsTable(projects: ProjectListItem[]): string {
689
+ const header = ["PROJECT_ID", "ALIAS", "PROJECT", "JOE", "TUNNEL"];
690
+ const rows = projects.map((p) => [
691
+ String(p.project_id),
692
+ p.alias ?? "-",
693
+ p.name ?? "-",
694
+ p.joe_ready ? "ready" : "no",
695
+ p.tunnel ? "yes" : "no",
696
+ ]);
697
+ const widths = header.map((h, i) =>
698
+ Math.max(h.length, ...rows.map((r) => r[i].length), 0)
699
+ );
700
+ const pad = (cells: string[]): string =>
701
+ cells.map((c, i) => c.padEnd(i === cells.length - 1 ? 0 : widths[i])).join(" ").trimEnd();
702
+ return [pad(header), ...rows.map(pad)].join("\n");
703
+ }