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/lib/joe.ts CHANGED
@@ -1,23 +1,30 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import * as crypto from "node:crypto";
4
- import { formatHttpError, maskSecret, normalizeBaseUrl, describeFetchError, redactSecretsForLog } from "./util";
5
- import * as config from "./config";
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 / MCP surface).
16
+ * Joe API v2 client (`postgres-ai` CLI surface) synchronous contract.
9
17
  *
10
- * Every Joe command is a thin wrapper over the platform rpc `v1.joe_command_submit`
11
- * (with the command's fixed `command`) plus a *client-side* status/result poll — there
12
- * is no DB-side sync wrapper (a single PostgREST transaction cannot both submit a
13
- * command and observe Joe's cross-connection callback). See SPEC §1/§5/§6.
14
- *
15
- * The backend rpcs (`joe_command_submit` / `joe_command_status` / `joe_command_result`
16
- * and the org-level projects listing) do not exist yet this module is written against
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 command set (SPEC §5/§6 + the per-command contract matrix). */
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
- /** Terminal + non-terminal command lifecycle states returned by the platform. */
35
- export type JoeStatus = "queued" | "running" | "done" | "error" | "timed_out";
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
- /** Commands that execute against the disposable clone (at-most-once posture). */
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 (documented contract — mocked in tests)
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
- * Command-appropriate result body (SPEC §1 + per-command contract matrix Part B).
84
- * A single interface is used with per-command optional keys; only the keys for the
85
- * submitted command are populated.
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 JoeCommandResult {
58
+ export interface JoeCommandOutput {
88
59
  command_id: string;
89
- command?: JoeCommand;
90
- status: JoeStatus;
91
- error: string | null;
92
- // plan / explain
60
+ status: JoeOutputStatus;
61
+ created_at?: string | null;
62
+ command?: string | null;
63
+ query?: string | null;
93
64
  queryid?: string | null;
94
- plan_fingerprint?: string | null;
65
+ response?: string | null;
95
66
  plan_text?: string | null;
96
67
  plan_json?: unknown;
97
- // exec
98
- row_count?: number | null;
99
- result_rows?: unknown[] | null;
100
- notices?: string[] | null;
101
- // hypo
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
- label: string | null;
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 (the M1a submit/status/result target). */
123
- instance_id: number | null;
124
- /** The project's active DBLAB instance id the SPEC §8 DBLab-companion resolver
125
- * passes this to `v1.dblab_api_call` (distinct from the Joe `instance_id`). */
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 the MCP caller controls
163
- // the debug flag (mirrors the access-token header masking above).
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, PT429 → 429, etc. The RPC's user-facing message
191
- // (e.g. the "AI features disabled for this org" text for AC36) rides in the
192
- // HTTP reason phrase (statusText), not the JSON body pass it through.
193
- throw new Error(formatHttpError(operation, response.status, text, response.statusText));
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
- throw new Error(`${operation}: failed to parse response: ${text}`);
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 SubmitCommandParams {
178
+ export interface StartCommandParams {
208
179
  apiKey: string;
209
180
  apiBaseUrl: string;
210
- command: JoeCommand;
211
- projectId: number;
212
- sql?: string | null;
213
- args?: Record<string, unknown> | null;
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
- /** Submit a Joe command (`v1.joe_command_submit`); returns the queued handle. */
221
- export async function submitCommand(params: SubmitCommandParams): Promise<JoeSubmitResponse> {
222
- const { apiKey, apiBaseUrl, command, projectId, sql, args, sessionId, idempotencyKey, debug } = params;
223
- if (!JOE_COMMANDS.includes(command)) {
224
- throw new Error(`Unknown Joe command: ${command}`);
225
- }
226
- const body: Record<string, unknown> = {
227
- project_id: projectId,
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
- return callRpc<JoeSubmitResponse>({
199
+ const commandId = await callRpc<unknown>({
239
200
  apiKey,
240
201
  apiBaseUrl,
241
- fn: "joe_command_submit",
242
- body,
243
- operation: `Failed to submit ${command} command`,
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 CommandIdParams {
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
- /** Poll a command's status metadata (`v1.joe_command_status`). */
256
- export async function getCommandStatus(params: CommandIdParams): Promise<JoeStatusResponse> {
257
- const { apiKey, apiBaseUrl, commandId, debug } = params;
258
- if (!commandId) {
259
- throw new Error("commandId is required");
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<JoeCommandResult>({
233
+ return callRpc<JoeCommandOutput>({
278
234
  apiKey,
279
235
  apiBaseUrl,
280
- fn: "joe_command_result",
236
+ fn: "joe_command_output",
281
237
  body: { command_id: commandId },
282
- operation: "Failed to fetch command result",
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
- id?: number;
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
- tunnel_ready?: boolean;
304
- instance_id?: number | null;
305
- dblab_instance_id?: number | null;
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: Number(projectId),
269
+ project_id: preserveIntegerId(row.project_id ?? 0),
312
270
  alias: row.alias ?? null,
313
271
  name: row.name ?? null,
314
- label: row.label ?? null,
315
- joe_ready: Boolean(row.joe_ready ?? row.joe_api_v2_enabled ?? false),
316
- tunnel: Boolean(row.tunnel ?? row.tunnel_ready ?? false),
317
- instance_id: row.instance_id == null ? null : Number(row.instance_id),
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; SPEC §6).
324
- * Backed by the general platform projects rpc, extended by item S.1 to surface the
325
- * per-project `joe_ready` + `tunnel` state so an agent knows which it may address.
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 (S.0)
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 ResolveProjectParams {
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 a numeric project id. A numeric ref is used
366
- * directly (no lookup); an alias/name/label is resolved against the projects
367
- * list, so `--project main-db` `--project 12` (SPEC §6, finding S.0).
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 resolveProjectId(params: ResolveProjectParams): Promise<number> {
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 = projects.find(
390
- (p) =>
391
- (p.alias !== null && p.alias.toLowerCase() === needle) ||
392
- (p.name !== null && p.name.toLowerCase() === needle) ||
393
- (p.label !== null && p.label.toLowerCase() === needle)
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
- return match.project_id;
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
- // Per-project session persistence (auto-threaded optimize loop, finding B-1)
360
+ // Raw command text builders (what Joe's /webui/command dispatches)
405
361
  // ---------------------------------------------------------------------------
406
362
 
407
- function sessionStorePath(dir?: string): string {
408
- return path.join(dir ?? config.getConfigDir(), "joe-sessions.json");
409
- }
410
-
411
- function readSessionStore(dir?: string): Record<string, string> {
412
- const file = sessionStorePath(dir);
413
- if (!fs.existsSync(file)) {
414
- return {};
415
- }
416
- try {
417
- const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
418
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
419
- return parsed as Record<string, string>;
420
- }
421
- } catch {
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 function clearStoredSessionId(projectId: number, dir?: string): void {
444
- const file = sessionStorePath(dir);
445
- if (!fs.existsSync(file)) {
446
- return;
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
- * Compute the idempotency key for a submit. Executing commands (`exec`/`explain`)
461
- * mint a STABLE key derived from `(project_id, command, sql, args)` so a
462
- * timeout-resubmit dedupes back to the original command_id (at-most-once SPEC M8);
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 computeIdempotencyKey(
466
- command: JoeCommand,
467
- projectId: number,
468
- sql: string | null | undefined,
469
- args: Record<string, unknown> | null | undefined
470
- ): string {
471
- if (EXECUTING_COMMANDS.has(command)) {
472
- const canonical = `${projectId}\n${command}\n${sql ?? ""}\n${JSON.stringify(args ?? null)}`;
473
- return `joe-${crypto.createHash("sha256").update(canonical).digest("hex")}`;
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
- // Submit-then-poll one-shot
430
+ // Run-then-poll one-shot
480
431
  // ---------------------------------------------------------------------------
481
432
 
482
433
  export interface RunCommandParams {
483
434
  apiKey: string;
484
435
  apiBaseUrl: string;
485
- command: JoeCommand;
486
- projectId: number;
487
- sql?: string | null;
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
- /** The session id returned by submit (thread back / persist for the next command). */
502
- sessionId: string | null;
503
- status: JoeStatus;
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
- * Submit a command then client-side poll status/result within the one-shot budget.
514
- * On a terminal state returns the result; on budget expiry returns a resume handle
515
- * (`budgetExpired: true`) so the caller can `pgai joe result <command_id>` later.
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 submitted = await submitCommand({
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: JoeStatus = submitted.status;
557
-
558
- // Poll status until terminal or the one-shot budget is exhausted.
559
- // eslint-disable-next-line no-constant-condition
560
- while (true) {
561
- const statusResp = await getCommandStatus({ apiKey, apiBaseUrl, commandId, debug });
562
- status = statusResp.status;
563
- if (TERMINAL_STATES.has(status)) {
564
- break;
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, sessionId: newSessionId, status, result: null, budgetExpired: true };
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 shared by the CLI and MCP surfaces
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: string;
586
- sql?: string | null;
587
- args?: Record<string, unknown> | null;
588
- /** Explicit `--session <id>` (overrides the stored/auto session). */
589
- session?: string | null;
590
- /** `--new-session` force a fresh clone (null session). */
591
- newSession?: boolean;
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
- projectId: number;
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
- * Resolve the project (id-or-alias), pick the session (explicit / stored / new),
609
- * run the submit-then-poll one-shot, and persist the returned session for the next
610
- * command on the same project the auto-threaded optimize loop.
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 projectId = await resolveProjectId({
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 sessionId: string | null;
622
- if (params.newSession) {
623
- clearStoredSessionId(projectId, params.sessionDir);
624
- sessionId = null;
625
- } else if (params.session) {
626
- sessionId = String(params.session);
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
- sessionId = readStoredSessionId(projectId, params.sessionDir);
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
- command: params.command,
635
- projectId,
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
- if (outcome.sessionId) {
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 (SPEC §6). The API returns no
666
- * server-authored recommendations (dropped as an injection vector), so the CLI
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
- /** Format a terminal command result as human-readable text (non-JSON mode). */
696
- export function formatJoeResult(command: JoeCommand, result: JoeCommandResult): string {
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
- switch (command) {
699
- case "plan":
700
- case "explain": {
701
- if (result.plan_text) {
702
- lines.push(result.plan_text);
703
- }
704
- for (const flag of clientSidePlanFlags(result.plan_json)) {
705
- lines.push(`⚑ ${flag}`);
706
- }
707
- if (result.queryid || result.plan_fingerprint) {
708
- lines.push(`(queryid ${result.queryid ?? "?"} · plan_fp ${result.plan_fingerprint ?? "?"})`);
709
- }
710
- break;
711
- }
712
- case "exec": {
713
- const notices = result.notices ?? [];
714
- const rowCount = result.row_count ?? 0;
715
- lines.push(`${notices.join(" ") || "OK"} · ${rowCount} row${rowCount === 1 ? "" : "s"}`);
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 the brief's fixed-width table. */
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) => [