postgresai 0.16.0-dev.10 → 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/CHANGELOG.md +11 -0
- package/README.md +1 -84
- package/bin/postgres-ai.ts +140 -540
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +790 -2282
- package/lib/checkup-summary.ts +25 -0
- package/lib/checkup.ts +88 -2
- package/lib/init.ts +29 -0
- package/lib/joe.ts +333 -391
- package/lib/mcp-server.ts +0 -625
- package/lib/util.ts +145 -29
- package/package.json +1 -1
- package/test/auth.test.ts +30 -1
- package/test/checkup.integration.test.ts +31 -21
- package/test/checkup.test.ts +48 -3
- package/test/init.test.ts +35 -0
- package/test/joe.cli.test.ts +355 -196
- package/test/joe.test.ts +565 -568
- package/test/schema-validation.test.ts +40 -0
- package/test/test-utils.ts +5 -0
- package/test/util.test.ts +116 -0
- package/lib/dblab.ts +0 -449
- package/test/dblab.cli.test.ts +0 -373
- package/test/dblab.test.ts +0 -488
- package/test/e2e/pgai-e2e-smoke.sh +0 -192
package/lib/joe.ts
CHANGED
|
@@ -1,23 +1,30 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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";
|
|
6
14
|
|
|
7
15
|
/**
|
|
8
|
-
* Joe API v2 client (`postgres-ai` CLI
|
|
16
|
+
* Joe API v2 client (`postgres-ai` CLI surface) — synchronous contract.
|
|
9
17
|
*
|
|
10
|
-
* Every Joe
|
|
11
|
-
* (
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* the documented contract and exercised entirely with mocked fetch responses.
|
|
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).
|
|
18
25
|
*/
|
|
19
26
|
|
|
20
|
-
/** The Joe
|
|
27
|
+
/** The Joe verb set (mirrors Joe's own dispatcher; `describe` = the \d family). */
|
|
21
28
|
export const JOE_COMMANDS = [
|
|
22
29
|
"plan",
|
|
23
30
|
"explain",
|
|
@@ -31,99 +38,52 @@ export const JOE_COMMANDS = [
|
|
|
31
38
|
|
|
32
39
|
export type JoeCommand = (typeof JOE_COMMANDS)[number];
|
|
33
40
|
|
|
34
|
-
/**
|
|
35
|
-
export type
|
|
36
|
-
|
|
37
|
-
const TERMINAL_STATES: ReadonlySet<JoeStatus> = new Set<JoeStatus>([
|
|
38
|
-
"done",
|
|
39
|
-
"error",
|
|
40
|
-
"timed_out",
|
|
41
|
-
]);
|
|
42
|
-
|
|
43
|
-
/** Commands whose result reaches the caller's LLM — gated on the org `ai_enabled`. */
|
|
44
|
-
export const AI_ENABLED_COMMANDS: ReadonlySet<JoeCommand> = new Set<JoeCommand>([
|
|
45
|
-
"plan",
|
|
46
|
-
"explain",
|
|
47
|
-
"exec",
|
|
48
|
-
"hypo",
|
|
49
|
-
"activity",
|
|
50
|
-
"describe",
|
|
51
|
-
]);
|
|
41
|
+
/** Output lifecycle: `pending` while Joe has not posted, then `ok`/`error`. */
|
|
42
|
+
export type JoeOutputStatus = "pending" | "ok" | "error";
|
|
52
43
|
|
|
53
|
-
/**
|
|
54
|
-
const EXECUTING_COMMANDS: ReadonlySet<JoeCommand> = new Set<JoeCommand>([
|
|
55
|
-
"exec",
|
|
56
|
-
"explain",
|
|
57
|
-
]);
|
|
58
|
-
|
|
59
|
-
/** Default one-shot poll budget (SPEC §6: ≤ 25 s, then resume by handle). */
|
|
44
|
+
/** Default one-shot poll budget (≤ 25 s, then resume by command id). */
|
|
60
45
|
export const DEFAULT_BUDGET_MS = 25_000;
|
|
61
46
|
const DEFAULT_POLL_INTERVAL_MS = 800;
|
|
62
47
|
|
|
63
48
|
// ---------------------------------------------------------------------------
|
|
64
|
-
// Response shapes (
|
|
49
|
+
// Response shapes (the joe_command_output contract — mocked in tests)
|
|
65
50
|
// ---------------------------------------------------------------------------
|
|
66
51
|
|
|
67
|
-
export interface JoeSubmitResponse {
|
|
68
|
-
command_id: string;
|
|
69
|
-
session_id: string | null;
|
|
70
|
-
status: JoeStatus;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface JoeStatusResponse {
|
|
74
|
-
command_id: string;
|
|
75
|
-
status: JoeStatus;
|
|
76
|
-
enqueued_at?: string | null;
|
|
77
|
-
started_at?: string | null;
|
|
78
|
-
finished_at?: string | null;
|
|
79
|
-
error?: string | null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
52
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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.
|
|
86
57
|
*/
|
|
87
|
-
export interface
|
|
58
|
+
export interface JoeCommandOutput {
|
|
88
59
|
command_id: string;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
60
|
+
status: JoeOutputStatus;
|
|
61
|
+
created_at?: string | null;
|
|
62
|
+
command?: string | null;
|
|
63
|
+
query?: string | null;
|
|
93
64
|
queryid?: string | null;
|
|
94
|
-
|
|
65
|
+
response?: string | null;
|
|
95
66
|
plan_text?: string | null;
|
|
96
67
|
plan_json?: unknown;
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
hypo_plan?: unknown;
|
|
103
|
-
hypo_used?: boolean | null;
|
|
104
|
-
// activity / describe
|
|
105
|
-
snapshot?: unknown;
|
|
106
|
-
// terminate
|
|
107
|
-
terminated?: boolean | null;
|
|
108
|
-
pid?: number | null;
|
|
109
|
-
// reset
|
|
110
|
-
reset?: boolean | null;
|
|
68
|
+
plan_execution_text?: string | null;
|
|
69
|
+
plan_execution_json?: unknown;
|
|
70
|
+
stats?: string | null;
|
|
71
|
+
recommendations?: string | null;
|
|
72
|
+
error?: string | null;
|
|
111
73
|
}
|
|
112
74
|
|
|
113
75
|
export interface ProjectListItem {
|
|
114
|
-
project_id: number;
|
|
76
|
+
project_id: number | string;
|
|
115
77
|
alias: string | null;
|
|
116
78
|
name: string | null;
|
|
117
|
-
|
|
118
|
-
/** Whether the project's single Joe instance is `joe_api_v2_enabled` + conformant. */
|
|
79
|
+
/** Whether the project's single Joe instance is ready for Joe API v2. */
|
|
119
80
|
joe_ready: boolean;
|
|
120
81
|
/** Whether the project's DBLab tunnel is connected. */
|
|
121
82
|
tunnel: boolean;
|
|
122
|
-
/** The project's active JOE instance id
|
|
123
|
-
instance_id: number | null;
|
|
124
|
-
/** The project's active DBLAB instance id
|
|
125
|
-
|
|
126
|
-
dblab_instance_id: number | null;
|
|
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;
|
|
127
87
|
}
|
|
128
88
|
|
|
129
89
|
// ---------------------------------------------------------------------------
|
|
@@ -137,6 +97,7 @@ interface RpcCallParams {
|
|
|
137
97
|
body: Record<string, unknown>;
|
|
138
98
|
operation: string;
|
|
139
99
|
debug?: boolean;
|
|
100
|
+
timeoutMs?: number;
|
|
140
101
|
}
|
|
141
102
|
|
|
142
103
|
async function callRpc<T>(params: RpcCallParams): Promise<T> {
|
|
@@ -159,19 +120,24 @@ async function callRpc<T>(params: RpcCallParams): Promise<T> {
|
|
|
159
120
|
const debugHeaders: Record<string, string> = { ...headers, "access-token": maskSecret(apiKey) };
|
|
160
121
|
console.error(`Debug: POST URL: ${url.toString()}`);
|
|
161
122
|
console.error(`Debug: Request headers: ${JSON.stringify(debugHeaders)}`);
|
|
162
|
-
// Redact credential-shaped fields before logging
|
|
163
|
-
//
|
|
123
|
+
// Redact credential-shaped fields before logging (mirrors the access-token
|
|
124
|
+
// header masking above).
|
|
164
125
|
console.error(`Debug: Request body: ${redactSecretsForLog(payload)}`);
|
|
165
126
|
}
|
|
166
127
|
|
|
167
128
|
let response: Response;
|
|
129
|
+
const requestTimeout = requestTimeoutSignal(params.timeoutMs);
|
|
168
130
|
try {
|
|
169
131
|
response = await fetch(url.toString(), {
|
|
170
132
|
method: "POST",
|
|
171
133
|
headers,
|
|
172
134
|
body: payload,
|
|
135
|
+
signal: requestTimeout.signal,
|
|
173
136
|
});
|
|
174
137
|
} catch (err) {
|
|
138
|
+
if (isFetchTimeout(err)) {
|
|
139
|
+
throw new HttpRequestTimeoutError(operation, requestTimeout.timeoutMs);
|
|
140
|
+
}
|
|
175
141
|
// A transport failure (connection refused, DNS, TLS, bad host/port) never
|
|
176
142
|
// reaches `response.ok`; undici throws with the real reason in `err.cause`.
|
|
177
143
|
// Surface it — a bare "fetch failed" hides which URL/why. See util.describeFetchError.
|
|
@@ -187,16 +153,21 @@ async function callRpc<T>(params: RpcCallParams): Promise<T> {
|
|
|
187
153
|
|
|
188
154
|
if (!response.ok) {
|
|
189
155
|
// PostgREST maps a custom `PTxyz` sqlstate to HTTP status `xyz`, so PT403 →
|
|
190
|
-
// HTTP 403, PT404 → 404,
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
throw new
|
|
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
|
+
);
|
|
194
163
|
}
|
|
195
164
|
|
|
196
165
|
try {
|
|
197
166
|
return JSON.parse(text) as T;
|
|
198
167
|
} catch {
|
|
199
|
-
|
|
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)}`);
|
|
200
171
|
}
|
|
201
172
|
}
|
|
202
173
|
|
|
@@ -204,83 +175,69 @@ async function callRpc<T>(params: RpcCallParams): Promise<T> {
|
|
|
204
175
|
// Individual rpc client functions
|
|
205
176
|
// ---------------------------------------------------------------------------
|
|
206
177
|
|
|
207
|
-
export interface
|
|
178
|
+
export interface StartCommandParams {
|
|
208
179
|
apiKey: string;
|
|
209
180
|
apiBaseUrl: string;
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
/** 64-bit session id, carried verbatim as a string to avoid precision loss. */
|
|
215
|
-
sessionId?: string | null;
|
|
216
|
-
idempotencyKey?: string | null;
|
|
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;
|
|
217
185
|
debug?: boolean;
|
|
186
|
+
timeoutMs?: number;
|
|
218
187
|
}
|
|
219
188
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
command
|
|
229
|
-
sql: sql ?? null,
|
|
230
|
-
args: args ?? null,
|
|
231
|
-
// session_id is a bigint server-side; sent as a numeric string when known so a
|
|
232
|
-
// 64-bit id survives JS number precision, null to start a fresh session/clone.
|
|
233
|
-
session_id: sessionId ?? null,
|
|
234
|
-
};
|
|
235
|
-
if (idempotencyKey) {
|
|
236
|
-
body.idempotency_key = idempotencyKey;
|
|
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");
|
|
237
198
|
}
|
|
238
|
-
|
|
199
|
+
const commandId = await callRpc<unknown>({
|
|
239
200
|
apiKey,
|
|
240
201
|
apiBaseUrl,
|
|
241
|
-
fn: "
|
|
242
|
-
body,
|
|
243
|
-
operation:
|
|
202
|
+
fn: "joe_command_run",
|
|
203
|
+
body: { instance_id: instanceId, command },
|
|
204
|
+
operation: "Failed to run Joe command",
|
|
244
205
|
debug,
|
|
206
|
+
timeoutMs,
|
|
245
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;
|
|
246
214
|
}
|
|
247
215
|
|
|
248
|
-
export interface
|
|
216
|
+
export interface CommandOutputParams {
|
|
249
217
|
apiKey: string;
|
|
250
218
|
apiBaseUrl: string;
|
|
251
219
|
commandId: string;
|
|
252
220
|
debug?: boolean;
|
|
221
|
+
timeoutMs?: number;
|
|
253
222
|
}
|
|
254
223
|
|
|
255
|
-
/**
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
return callRpc<JoeStatusResponse>({
|
|
262
|
-
apiKey,
|
|
263
|
-
apiBaseUrl,
|
|
264
|
-
fn: "joe_command_status",
|
|
265
|
-
body: { command_id: commandId },
|
|
266
|
-
operation: "Failed to fetch command status",
|
|
267
|
-
debug,
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/** Fetch a command's result body (`v1.joe_command_result`). */
|
|
272
|
-
export async function getCommandResult(params: CommandIdParams): Promise<JoeCommandResult> {
|
|
273
|
-
const { apiKey, apiBaseUrl, commandId, debug } = params;
|
|
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;
|
|
274
230
|
if (!commandId) {
|
|
275
231
|
throw new Error("commandId is required");
|
|
276
232
|
}
|
|
277
|
-
return callRpc<
|
|
233
|
+
return callRpc<JoeCommandOutput>({
|
|
278
234
|
apiKey,
|
|
279
235
|
apiBaseUrl,
|
|
280
|
-
fn: "
|
|
236
|
+
fn: "joe_command_output",
|
|
281
237
|
body: { command_id: commandId },
|
|
282
|
-
operation: "Failed to fetch command
|
|
238
|
+
operation: "Failed to fetch command output",
|
|
283
239
|
debug,
|
|
240
|
+
timeoutMs,
|
|
284
241
|
});
|
|
285
242
|
}
|
|
286
243
|
|
|
@@ -292,37 +249,37 @@ export interface ListProjectsParams {
|
|
|
292
249
|
}
|
|
293
250
|
|
|
294
251
|
interface RawProjectRow {
|
|
295
|
-
|
|
296
|
-
project_id?: number;
|
|
252
|
+
project_id?: number | string;
|
|
297
253
|
alias?: string | null;
|
|
298
254
|
name?: string | null;
|
|
299
|
-
label?: string | null;
|
|
300
255
|
joe_ready?: boolean;
|
|
301
|
-
joe_api_v2_enabled?: boolean;
|
|
302
256
|
tunnel?: boolean;
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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;
|
|
306
265
|
}
|
|
307
266
|
|
|
308
267
|
function normalizeProjectRow(row: RawProjectRow): ProjectListItem {
|
|
309
|
-
const projectId = row.project_id ?? row.id ?? 0;
|
|
310
268
|
return {
|
|
311
|
-
project_id:
|
|
269
|
+
project_id: preserveIntegerId(row.project_id ?? 0),
|
|
312
270
|
alias: row.alias ?? null,
|
|
313
271
|
name: row.name ?? null,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
dblab_instance_id: row.dblab_instance_id == null ? null : Number(row.dblab_instance_id),
|
|
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),
|
|
319
276
|
};
|
|
320
277
|
}
|
|
321
278
|
|
|
322
279
|
/**
|
|
323
|
-
* List the org's projects (org-level discovery — NOT a Joe endpoint
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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.
|
|
326
283
|
*/
|
|
327
284
|
export async function listProjects(params: ListProjectsParams): Promise<ProjectListItem[]> {
|
|
328
285
|
const { apiKey, apiBaseUrl, orgId, debug } = params;
|
|
@@ -345,7 +302,7 @@ export async function listProjects(params: ListProjectsParams): Promise<ProjectL
|
|
|
345
302
|
}
|
|
346
303
|
|
|
347
304
|
// ---------------------------------------------------------------------------
|
|
348
|
-
// Project id-or-alias resolution
|
|
305
|
+
// Project id-or-alias → Joe instance resolution
|
|
349
306
|
// ---------------------------------------------------------------------------
|
|
350
307
|
|
|
351
308
|
/** A bare numeric `--project` value is a project id; anything else is an alias/name. */
|
|
@@ -353,7 +310,7 @@ export function isNumericProjectRef(ref: string): boolean {
|
|
|
353
310
|
return /^[0-9]+$/.test(ref.trim());
|
|
354
311
|
}
|
|
355
312
|
|
|
356
|
-
export interface
|
|
313
|
+
export interface ResolveInstanceParams {
|
|
357
314
|
apiKey: string;
|
|
358
315
|
apiBaseUrl: string;
|
|
359
316
|
project: string;
|
|
@@ -362,23 +319,16 @@ export interface ResolveProjectParams {
|
|
|
362
319
|
}
|
|
363
320
|
|
|
364
321
|
/**
|
|
365
|
-
* Resolve `--project <id|alias>` to
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
* NOTE(joe-v2): deliberately kept parallel to dblab.ts `resolveDblabInstanceId`
|
|
370
|
-
* (which additionally needs the project's `dblab_instance_id`); both accept the
|
|
371
|
-
* SAME reference set — id, alias, name, label. Full resolver unification is a
|
|
372
|
-
* deferred refactor.
|
|
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).
|
|
373
326
|
*/
|
|
374
|
-
export async function
|
|
327
|
+
export async function resolveJoeInstanceId(params: ResolveInstanceParams): Promise<number | string> {
|
|
375
328
|
const ref = String(params.project ?? "").trim();
|
|
376
329
|
if (!ref) {
|
|
377
330
|
throw new Error("project is required (--project <id|alias>)");
|
|
378
331
|
}
|
|
379
|
-
if (isNumericProjectRef(ref)) {
|
|
380
|
-
return Number(ref);
|
|
381
|
-
}
|
|
382
332
|
const projects = await listProjects({
|
|
383
333
|
apiKey: params.apiKey,
|
|
384
334
|
apiBaseUrl: params.apiBaseUrl,
|
|
@@ -386,108 +336,106 @@ export async function resolveProjectId(params: ResolveProjectParams): Promise<nu
|
|
|
386
336
|
debug: params.debug,
|
|
387
337
|
});
|
|
388
338
|
const needle = ref.toLowerCase();
|
|
389
|
-
const match =
|
|
390
|
-
(p) =>
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
);
|
|
395
346
|
if (!match) {
|
|
396
347
|
throw new Error(
|
|
397
|
-
`Project not found for alias/name '${ref}'. Run 'pgai projects' to see available projects.`
|
|
348
|
+
`Project not found for id/alias/name '${ref}'. Run 'pgai projects' to see available projects.`
|
|
398
349
|
);
|
|
399
350
|
}
|
|
400
|
-
|
|
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;
|
|
401
357
|
}
|
|
402
358
|
|
|
403
359
|
// ---------------------------------------------------------------------------
|
|
404
|
-
//
|
|
360
|
+
// Raw command text builders (what Joe's /webui/command dispatches)
|
|
405
361
|
// ---------------------------------------------------------------------------
|
|
406
362
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
// Corrupt store — treat as empty rather than failing a command.
|
|
423
|
-
}
|
|
424
|
-
return {};
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
export function readStoredSessionId(projectId: number, dir?: string): string | null {
|
|
428
|
-
const store = readSessionStore(dir);
|
|
429
|
-
const value = store[String(projectId)];
|
|
430
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
export function writeStoredSessionId(projectId: number, sessionId: string, dir?: string): void {
|
|
434
|
-
const baseDir = dir ?? config.getConfigDir();
|
|
435
|
-
if (!fs.existsSync(baseDir)) {
|
|
436
|
-
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
|
437
|
-
}
|
|
438
|
-
const store = readSessionStore(dir);
|
|
439
|
-
store[String(projectId)] = sessionId;
|
|
440
|
-
fs.writeFileSync(sessionStorePath(dir), JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
441
|
-
}
|
|
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;
|
|
442
378
|
|
|
443
|
-
export
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const store = readSessionStore(dir);
|
|
449
|
-
if (String(projectId) in store) {
|
|
450
|
-
delete store[String(projectId)];
|
|
451
|
-
fs.writeFileSync(file, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
452
|
-
}
|
|
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;
|
|
453
384
|
}
|
|
454
385
|
|
|
455
|
-
// ---------------------------------------------------------------------------
|
|
456
|
-
// Idempotency keys
|
|
457
|
-
// ---------------------------------------------------------------------------
|
|
458
|
-
|
|
459
386
|
/**
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
* non-executing commands generate a fresh per-invocation key.
|
|
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`).
|
|
464
390
|
*/
|
|
465
|
-
export function
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
}
|
|
474
426
|
}
|
|
475
|
-
return `joe-${crypto.randomUUID()}`;
|
|
476
427
|
}
|
|
477
428
|
|
|
478
429
|
// ---------------------------------------------------------------------------
|
|
479
|
-
//
|
|
430
|
+
// Run-then-poll one-shot
|
|
480
431
|
// ---------------------------------------------------------------------------
|
|
481
432
|
|
|
482
433
|
export interface RunCommandParams {
|
|
483
434
|
apiKey: string;
|
|
484
435
|
apiBaseUrl: string;
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
args?: Record<string, unknown> | null;
|
|
489
|
-
sessionId?: string | null;
|
|
490
|
-
idempotencyKey?: string | null;
|
|
436
|
+
instanceId: number | string;
|
|
437
|
+
/** The RAW command text (see {@link buildJoeCommandText}). */
|
|
438
|
+
command: string;
|
|
491
439
|
budgetMs?: number;
|
|
492
440
|
pollIntervalMs?: number;
|
|
493
441
|
debug?: boolean;
|
|
@@ -498,11 +446,9 @@ export interface RunCommandParams {
|
|
|
498
446
|
|
|
499
447
|
export interface RunCommandOutcome {
|
|
500
448
|
commandId: string;
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
/** Populated once the command reaches a terminal state (done/error/timed_out). */
|
|
505
|
-
result: JoeCommandResult | null;
|
|
449
|
+
status: JoeOutputStatus;
|
|
450
|
+
/** Populated once the command reaches a terminal state (ok/error). */
|
|
451
|
+
output: JoeCommandOutput | null;
|
|
506
452
|
/** True when the ≤ budget one-shot expired before a terminal state — resume by id. */
|
|
507
453
|
budgetExpired: boolean;
|
|
508
454
|
}
|
|
@@ -510,132 +456,145 @@ export interface RunCommandOutcome {
|
|
|
510
456
|
const defaultSleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
511
457
|
|
|
512
458
|
/**
|
|
513
|
-
*
|
|
514
|
-
* On a terminal state returns the
|
|
515
|
-
* (`budgetExpired: true`) so the caller can
|
|
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.
|
|
516
463
|
*/
|
|
517
464
|
export async function runCommand(params: RunCommandParams): Promise<RunCommandOutcome> {
|
|
518
|
-
const {
|
|
519
|
-
apiKey,
|
|
520
|
-
apiBaseUrl,
|
|
521
|
-
command,
|
|
522
|
-
projectId,
|
|
523
|
-
sql,
|
|
524
|
-
args,
|
|
525
|
-
sessionId,
|
|
526
|
-
debug,
|
|
527
|
-
} = params;
|
|
465
|
+
const { apiKey, apiBaseUrl, instanceId, command, debug } = params;
|
|
528
466
|
// Defensive: only a finite budget is honored. NaN survives `??` (it is
|
|
529
467
|
// neither null nor undefined) and would make `deadline` NaN — `now() >= NaN`
|
|
530
468
|
// is always false, i.e. an UNBOUNDED poll loop.
|
|
531
469
|
const budgetMs =
|
|
532
|
-
typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs)
|
|
470
|
+
typeof params.budgetMs === "number" && Number.isFinite(params.budgetMs) && params.budgetMs >= 0
|
|
533
471
|
? params.budgetMs
|
|
534
472
|
: DEFAULT_BUDGET_MS;
|
|
535
473
|
const pollIntervalMs = params.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
536
474
|
const now = params.now ?? Date.now;
|
|
537
475
|
const sleep = params.sleep ?? defaultSleep;
|
|
538
|
-
const idempotencyKey =
|
|
539
|
-
params.idempotencyKey ?? computeIdempotencyKey(command, projectId, sql, args);
|
|
540
476
|
|
|
541
|
-
const
|
|
542
|
-
apiKey,
|
|
543
|
-
apiBaseUrl,
|
|
544
|
-
command,
|
|
545
|
-
projectId,
|
|
546
|
-
sql,
|
|
547
|
-
args,
|
|
548
|
-
sessionId,
|
|
549
|
-
idempotencyKey,
|
|
550
|
-
debug,
|
|
551
|
-
});
|
|
477
|
+
const commandId = await startCommand({ apiKey, apiBaseUrl, instanceId, command, debug });
|
|
552
478
|
|
|
553
|
-
const commandId = submitted.command_id;
|
|
554
|
-
const newSessionId = submitted.session_id ?? sessionId ?? null;
|
|
555
479
|
const deadline = now() + budgetMs;
|
|
556
|
-
let status:
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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 };
|
|
565
524
|
}
|
|
566
525
|
if (now() >= deadline) {
|
|
567
|
-
return { commandId,
|
|
526
|
+
return { commandId, status, output: null, budgetExpired: true };
|
|
568
527
|
}
|
|
569
528
|
await sleep(pollIntervalMs);
|
|
570
529
|
}
|
|
571
|
-
|
|
572
|
-
const result = await getCommandResult({ apiKey, apiBaseUrl, commandId, debug });
|
|
573
|
-
return { commandId, sessionId: newSessionId, status, result, budgetExpired: false };
|
|
574
530
|
}
|
|
575
531
|
|
|
576
532
|
// ---------------------------------------------------------------------------
|
|
577
|
-
// High-level orchestrator
|
|
533
|
+
// High-level orchestrator (the CLI verb surface)
|
|
578
534
|
// ---------------------------------------------------------------------------
|
|
579
535
|
|
|
580
536
|
export interface ExecuteJoeParams {
|
|
581
537
|
apiKey: string;
|
|
582
538
|
apiBaseUrl: string;
|
|
583
539
|
command: JoeCommand;
|
|
584
|
-
/** Raw `--project <id|alias>` value. */
|
|
585
|
-
project
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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;
|
|
592
550
|
orgId?: number;
|
|
593
551
|
budgetMs?: number;
|
|
594
552
|
pollIntervalMs?: number;
|
|
595
553
|
debug?: boolean;
|
|
596
|
-
/** Session-store directory (defaults to the user config dir). */
|
|
597
|
-
sessionDir?: string;
|
|
598
554
|
now?: () => number;
|
|
599
555
|
sleep?: (ms: number) => Promise<void>;
|
|
600
556
|
}
|
|
601
557
|
|
|
602
558
|
export interface ExecuteJoeOutcome extends RunCommandOutcome {
|
|
603
559
|
command: JoeCommand;
|
|
604
|
-
|
|
560
|
+
instanceId: number | string;
|
|
561
|
+
/** The raw text that went on the wire (debugging/tests). */
|
|
562
|
+
commandText: string;
|
|
605
563
|
}
|
|
606
564
|
|
|
607
565
|
/**
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
*
|
|
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.
|
|
611
570
|
*/
|
|
612
571
|
export async function executeJoeCommand(params: ExecuteJoeParams): Promise<ExecuteJoeOutcome> {
|
|
613
|
-
const
|
|
614
|
-
apiKey: params.apiKey,
|
|
615
|
-
apiBaseUrl: params.apiBaseUrl,
|
|
616
|
-
project: params.project,
|
|
617
|
-
orgId: params.orgId,
|
|
618
|
-
debug: params.debug,
|
|
619
|
-
});
|
|
572
|
+
const commandText = buildJoeCommandText(params.command, params.input);
|
|
620
573
|
|
|
621
|
-
let
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
+
});
|
|
627
589
|
} else {
|
|
628
|
-
|
|
590
|
+
throw new Error("either instanceId or project is required");
|
|
629
591
|
}
|
|
630
592
|
|
|
631
593
|
const outcome = await runCommand({
|
|
632
594
|
apiKey: params.apiKey,
|
|
633
595
|
apiBaseUrl: params.apiBaseUrl,
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
sql: params.sql,
|
|
637
|
-
args: params.args,
|
|
638
|
-
sessionId,
|
|
596
|
+
instanceId,
|
|
597
|
+
command: commandText,
|
|
639
598
|
budgetMs: params.budgetMs,
|
|
640
599
|
pollIntervalMs: params.pollIntervalMs,
|
|
641
600
|
debug: params.debug,
|
|
@@ -643,11 +602,7 @@ export async function executeJoeCommand(params: ExecuteJoeParams): Promise<Execu
|
|
|
643
602
|
sleep: params.sleep,
|
|
644
603
|
});
|
|
645
604
|
|
|
646
|
-
|
|
647
|
-
writeStoredSessionId(projectId, outcome.sessionId, params.sessionDir);
|
|
648
|
-
}
|
|
649
|
-
|
|
650
|
-
return { ...outcome, command: params.command, projectId };
|
|
605
|
+
return { ...outcome, command: params.command, instanceId, commandText };
|
|
651
606
|
}
|
|
652
607
|
|
|
653
608
|
// ---------------------------------------------------------------------------
|
|
@@ -662,9 +617,8 @@ interface PlanNode {
|
|
|
662
617
|
}
|
|
663
618
|
|
|
664
619
|
/**
|
|
665
|
-
* Lightweight CLIENT-SIDE plan flagging (
|
|
666
|
-
*
|
|
667
|
-
* flags obvious issues (e.g. a Seq Scan) from the returned plan_json itself.
|
|
620
|
+
* Lightweight CLIENT-SIDE plan flagging: flag obvious issues (e.g. a Seq Scan)
|
|
621
|
+
* from the returned structured plan_json itself.
|
|
668
622
|
*/
|
|
669
623
|
export function clientSidePlanFlags(planJson: unknown): string[] {
|
|
670
624
|
const flags: string[] = [];
|
|
@@ -685,6 +639,15 @@ export function clientSidePlanFlags(planJson: unknown): string[] {
|
|
|
685
639
|
}
|
|
686
640
|
}
|
|
687
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
|
+
}
|
|
688
651
|
if (planJson && typeof planJson === "object") {
|
|
689
652
|
const root = planJson as { Plan?: PlanNode };
|
|
690
653
|
walk(root.Plan ?? (planJson as PlanNode));
|
|
@@ -692,57 +655,36 @@ export function clientSidePlanFlags(planJson: unknown): string[] {
|
|
|
692
655
|
return flags;
|
|
693
656
|
}
|
|
694
657
|
|
|
695
|
-
/**
|
|
696
|
-
|
|
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 {
|
|
697
664
|
const lines: string[] = [];
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
if (result.result_rows && result.result_rows.length > 0) {
|
|
717
|
-
lines.push(JSON.stringify(result.result_rows, null, 2));
|
|
718
|
-
}
|
|
719
|
-
break;
|
|
720
|
-
}
|
|
721
|
-
case "hypo": {
|
|
722
|
-
lines.push(result.hypo_used ? "hypothetical index would be used" : "hypothetical index would NOT be used");
|
|
723
|
-
if (result.hypo_plan !== undefined) {
|
|
724
|
-
lines.push(JSON.stringify(result.hypo_plan, null, 2));
|
|
725
|
-
}
|
|
726
|
-
break;
|
|
727
|
-
}
|
|
728
|
-
case "activity":
|
|
729
|
-
case "describe": {
|
|
730
|
-
lines.push(JSON.stringify(result.snapshot ?? null, null, 2));
|
|
731
|
-
break;
|
|
732
|
-
}
|
|
733
|
-
case "terminate": {
|
|
734
|
-
lines.push(`terminated ${result.terminated ? "yes" : "no"} · pid ${result.pid ?? "?"}`);
|
|
735
|
-
break;
|
|
736
|
-
}
|
|
737
|
-
case "reset": {
|
|
738
|
-
lines.push(`reset ${result.reset ? "ok" : "no"}`);
|
|
739
|
-
break;
|
|
740
|
-
}
|
|
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})`);
|
|
741
683
|
}
|
|
742
684
|
return lines.join("\n");
|
|
743
685
|
}
|
|
744
686
|
|
|
745
|
-
/** Render `pgai projects` as
|
|
687
|
+
/** Render `pgai projects` as a fixed-width table. */
|
|
746
688
|
export function formatProjectsTable(projects: ProjectListItem[]): string {
|
|
747
689
|
const header = ["PROJECT_ID", "ALIAS", "PROJECT", "JOE", "TUNNEL"];
|
|
748
690
|
const rows = projects.map((p) => [
|