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.
@@ -2,9 +2,6 @@ import { describe, test, expect } from "bun:test";
2
2
  import { resolve } from "path";
3
3
  import { mkdtempSync } from "fs";
4
4
  import { tmpdir } from "os";
5
- // Importing the bin module is safe: argv parsing is gated on `import.meta.main`
6
- // (same pattern as monitoring.test.ts).
7
- import { inferCommandFromResult } from "../bin/postgres-ai";
8
5
 
9
6
  // Sync runner for OFFLINE tests only (help / fail-fast). Never use it for a test
10
7
  // that round-trips the in-process Bun.serve: spawnSync blocks the JS event loop, so
@@ -39,7 +36,7 @@ async function runCliAsync(args: string[], env: Record<string, string> = {}) {
39
36
  return { status, stdout, stderr };
40
37
  }
41
38
 
42
- /** Isolate config so tests never touch real user state; a shared dir threads sessions. */
39
+ /** Isolate config so tests never touch real user state. */
43
40
  function isolatedEnv(extra: Record<string, string> = {}) {
44
41
  const cfgHome = mkdtempSync(resolve(tmpdir(), "joe-cli-test-"));
45
42
  return { XDG_CONFIG_HOME: cfgHome, HOME: cfgHome, ...extra };
@@ -51,15 +48,20 @@ interface RecordedRequest {
51
48
  }
52
49
 
53
50
  /**
54
- * Fake PostgREST server for the Joe rpcs (backend not built yet). A `plan` on a
55
- * project whose id is COLD_PROJECT stays `running` (to exercise the resume path);
56
- * project id 999 returns the ai_enabled=false PT403; everything else completes.
51
+ * Fake PostgREST server for the sync Joe rpcs. Instance 55 ("cold-db") never
52
+ * posts a result (stays `pending`, exercising the resume path); instance 66
53
+ * ("no-role") returns the Joe-API-v2 role-gate PT403; instance 3 completes.
54
+ * Command ids are ABOVE 2^53 so any parseInt round-trip in the CLI corrupts
55
+ * them and the string-preservation assertions fail.
57
56
  */
58
57
  function startFakeApi() {
59
58
  const requests: RecordedRequest[] = [];
60
- const COLD_PROJECT = 77;
61
- let nextId = 4711;
62
- const commands = new Map<string, { command: string; projectId: number }>();
59
+ const COLD_INSTANCE = 55;
60
+ const FORBIDDEN_INSTANCE = 66;
61
+ const ERROR_INSTANCE = 33;
62
+ const FLAKY_INSTANCE = 44;
63
+ let nextId = 9007199254740993000n; // > Number.MAX_SAFE_INTEGER
64
+ const commands = new Map<string, { command: string; instanceId: number }>();
63
65
 
64
66
  const server = Bun.serve({
65
67
  hostname: "127.0.0.1",
@@ -80,51 +82,94 @@ function startFakeApi() {
80
82
 
81
83
  if (req.method === "POST" && url.pathname.endsWith("/rpc/projects_list")) {
82
84
  return respond([
83
- { id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true },
84
- { id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true },
85
+ { project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3 },
86
+ { project_id: 77, alias: "cold-db", name: "Cold DB", joe_ready: true, tunnel: true, instance_id: COLD_INSTANCE },
87
+ { project_id: 99, alias: "no-role", name: "No Role", joe_ready: true, tunnel: true, instance_id: FORBIDDEN_INSTANCE },
88
+ { project_id: 14, alias: "no-joe", name: "No Joe", joe_ready: false, tunnel: false, instance_id: null },
89
+ { project_id: 15, alias: "err-db", name: "Err DB", joe_ready: true, tunnel: true, instance_id: ERROR_INSTANCE },
90
+ { project_id: 16, alias: "flaky-db", name: "Flaky DB", joe_ready: true, tunnel: true, instance_id: FLAKY_INSTANCE },
85
91
  ]);
86
92
  }
87
93
 
88
- if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_submit")) {
89
- const projectId = Number(body.project_id);
90
- if (projectId === 999) {
91
- return respond({ message: "AI features disabled for this org — enable in AI Assistant Settings" }, 403);
94
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_run")) {
95
+ const instanceId = Number(body.instance_id);
96
+ if (instanceId === FORBIDDEN_INSTANCE) {
97
+ return respond(
98
+ { code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." },
99
+ 403
100
+ );
92
101
  }
93
102
  const commandId = String(nextId++);
94
- commands.set(commandId, { command: String(body.command), projectId });
95
- // Echo the incoming session_id, or hand back a fresh one for a null session.
96
- const sessionId = body.session_id != null ? String(body.session_id) : "88";
97
- return respond({ command_id: commandId, session_id: sessionId, status: "queued" });
103
+ commands.set(commandId, { command: String(body.command), instanceId });
104
+ // The rpc returns the id as a bare JSON string.
105
+ return respond(commandId);
98
106
  }
99
107
 
100
- if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_status")) {
101
- const commandId = String(body.command_id);
102
- const cmd = commands.get(commandId);
103
- const status = cmd && cmd.projectId === COLD_PROJECT ? "running" : "done";
104
- return respond({ command_id: commandId, status, error: null });
105
- }
106
-
107
- if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_result")) {
108
+ if (req.method === "POST" && url.pathname.endsWith("/rpc/joe_command_output")) {
108
109
  const commandId = String(body.command_id);
109
110
  if (commandId === "424242") {
110
- // A server-side terminal timeout (the dispatcher gave up on the command).
111
- return respond({ command_id: commandId, status: "timed_out", error: null });
111
+ // Terminal failure without a structured row (e.g. clone init failed).
112
+ return respond({ command_id: commandId, status: "error", created_at: "2026-07-22T10:00:00", error: "ERROR: something failed" });
112
113
  }
113
- if (commandId === "424243") {
114
- // A terminal error result (sanitized message, per joe_error_sanitize).
115
- return respond({ command_id: commandId, command: "exec", status: "error", error: "Query error" });
114
+ if (commandId === "424244") {
115
+ return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
116
116
  }
117
117
  const cmd = commands.get(commandId);
118
- if (cmd?.command === "exec") {
119
- return respond({ command_id: commandId, command: "exec", status: "done", row_count: 0, result_rows: [], notices: ["CREATE INDEX"], error: null });
118
+ if (!cmd) {
119
+ return respond({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404);
120
+ }
121
+ if (cmd.instanceId === COLD_INSTANCE) {
122
+ return respond({ command_id: commandId, status: "pending", created_at: "2026-07-22T10:00:00" });
123
+ }
124
+ if (cmd.instanceId === ERROR_INSTANCE) {
125
+ // Joe posted a terminal failure for this command.
126
+ return respond({
127
+ command_id: commandId,
128
+ status: "error",
129
+ created_at: "2026-07-22T10:00:00",
130
+ queryid: null,
131
+ plan_json: null,
132
+ plan_text: null,
133
+ error: "ERROR: relation \"nope\" does not exist",
134
+ });
135
+ }
136
+ if (cmd.instanceId === FLAKY_INSTANCE) {
137
+ // The output endpoint is down (proxy hiccup / pod restart) — every
138
+ // poll 502s while the command id remains perfectly valid.
139
+ return new Response("Bad Gateway", { status: 502 });
140
+ }
141
+ if (cmd.command.startsWith("exec ") || cmd.command.startsWith("\\d")) {
142
+ return respond({
143
+ command_id: commandId,
144
+ status: "ok",
145
+ created_at: "2026-07-22T10:00:00",
146
+ command: cmd.command.split(" ")[0],
147
+ query: null,
148
+ queryid: null,
149
+ response: cmd.command.startsWith("exec ") ? "CREATE INDEX" : "Table \"public.users\"",
150
+ plan_text: null,
151
+ plan_json: null,
152
+ plan_execution_text: null,
153
+ plan_execution_json: null,
154
+ stats: null,
155
+ recommendations: null,
156
+ error: null,
157
+ });
120
158
  }
121
159
  return respond({
122
160
  command_id: commandId,
123
- status: "done",
161
+ status: "ok",
162
+ created_at: "2026-07-22T10:00:00",
163
+ command: cmd.command.split(" ")[0],
164
+ query: cmd.command.replace(/^\S+\s*/, "") || null,
124
165
  queryid: "7712349901234567890",
125
- plan_fingerprint: "a1b2c3d4",
166
+ response: null,
126
167
  plan_text: "Seq Scan on users (cost=0.00..48210.00 rows=1 width=812)",
127
- plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
168
+ plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
169
+ plan_execution_text: "Seq Scan on users (actual time=0.05..12.30 rows=1 loops=1)",
170
+ plan_execution_json: [{ Plan: {} }],
171
+ stats: "Time: 12.4 ms",
172
+ recommendations: null,
128
173
  error: null,
129
174
  });
130
175
  }
@@ -138,38 +183,37 @@ function startFakeApi() {
138
183
  }
139
184
 
140
185
  describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
141
- test("top-level help lists the joe + dblab groups and top-level projects", () => {
186
+ test("top-level help lists the joe group and top-level projects", () => {
142
187
  const r = runCli(["--help"], isolatedEnv());
143
188
  const out = `${r.stdout}\n${r.stderr}`;
144
- // Joe verbs are grouped under `joe`; DBLab verbs under `dblab`. `projects`
145
- // stays top-level (org-level, not a Joe endpoint).
146
- for (const group of ["joe", "dblab", "projects"]) {
147
- expect(out).toContain(group);
148
- }
189
+ expect(out).toContain("joe");
190
+ expect(out).toContain("projects");
149
191
  });
150
192
 
151
- test("pgai joe --help lists all the Joe verbs", () => {
193
+ test("pgai joe --help lists all the Joe verbs (and no async-era flags)", () => {
152
194
  const r = runCli(["joe", "--help"], isolatedEnv());
153
195
  expect(r.status).toBe(0);
154
196
  const out = `${r.stdout}\n${r.stderr}`;
155
- for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result", "status", "history"]) {
197
+ for (const verb of ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe", "result"]) {
156
198
  expect(out).toContain(verb);
157
199
  }
200
+ // The async surface's session flags and status/history verbs are gone.
201
+ expect(out).not.toContain("--session");
202
+ expect(out).not.toContain("new-session");
203
+ expect(out).not.toContain("history");
158
204
  });
159
205
 
160
- test("pgai joe history (M1b) is recognized and degrades cleanly", () => {
161
- // The brief's §2 documents `pgai joe history "users email" --project 12`, but the
162
- // history-search backend is M1b/not built. The command must be recognized (NOT a
163
- // Commander "unknown command") and exit 1 with an informative pending message.
164
- const r = runCli(["joe", "history", "users email", "--project", "12"], isolatedEnv({ PGAI_API_KEY: "k" }));
165
- expect(r.status).toBe(1);
206
+ test("pgai joe plan --help offers --instance-id and --budget but not --session", () => {
207
+ const r = runCli(["joe", "plan", "--help"], isolatedEnv());
208
+ expect(r.status).toBe(0);
166
209
  const out = `${r.stdout}\n${r.stderr}`;
167
- expect(out).toContain("not available yet");
168
- expect(out).toContain("M1b");
169
- expect(out).not.toContain("unknown command");
210
+ expect(out).toContain("--instance-id");
211
+ expect(out).toContain("--budget");
212
+ expect(out).toContain("--project");
213
+ expect(out).not.toContain("--session");
170
214
  });
171
215
 
172
- test("pgai projects prints the brief's table", async () => {
216
+ test("pgai projects prints the table", async () => {
173
217
  const api = startFakeApi();
174
218
  try {
175
219
  const r = await runCliAsync(["projects"], isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
@@ -187,7 +231,23 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
187
231
  }
188
232
  });
189
233
 
190
- test("pgai joe plan --project 12 submits command='plan' and prints the plan", async () => {
234
+ test("pgai projects --json prints a machine-readable project array", async () => {
235
+ const api = startFakeApi();
236
+ try {
237
+ const r = await runCliAsync(
238
+ ["projects", "--json"],
239
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
240
+ );
241
+ expect(r.status).toBe(0);
242
+ const projects = JSON.parse(r.stdout) as Array<{ project_id: number; alias: string }>;
243
+ expect(projects).toBeArray();
244
+ expect(projects[0]).toMatchObject({ project_id: 12, alias: "main-db" });
245
+ } finally {
246
+ api.stop();
247
+ }
248
+ });
249
+
250
+ test("pgai joe plan --project 12 sends the RAW text against the project's instance", async () => {
191
251
  const api = startFakeApi();
192
252
  try {
193
253
  const r = await runCliAsync(
@@ -196,13 +256,48 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
196
256
  );
197
257
  expect(r.status).toBe(0);
198
258
  expect(r.stdout).toContain("Seq Scan on users");
199
- // client-side plan flag
259
+ // client-side plan flag from the structured plan_json
200
260
  expect(r.stdout).toContain("⚑");
201
- const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
202
- expect(submit?.body.command).toBe("plan");
203
- expect(submit?.body.project_id).toBe(12);
204
- // numeric project => no projects_list lookup
205
- expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
261
+ const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
262
+ // The run rpc keys on the INSTANCE id (3), resolved from project 12, and
263
+ // the command is the raw text with the verb prefix — no structured body.
264
+ expect(run?.body).toEqual({
265
+ instance_id: 3,
266
+ command: "plan select * from users where email = 'x@acme.io'",
267
+ });
268
+ // A numeric project still needs the listing — the instance id lives there.
269
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
270
+ } finally {
271
+ api.stop();
272
+ }
273
+ });
274
+
275
+ test("pgai joe plan --json prints the full output row as JSON", async () => {
276
+ const api = startFakeApi();
277
+ try {
278
+ const r = await runCliAsync(
279
+ ["joe", "plan", "select 1", "--project", "12", "--json"],
280
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
281
+ );
282
+ expect(r.status).toBe(0);
283
+ const result = JSON.parse(r.stdout) as {
284
+ command_id: string;
285
+ status: string;
286
+ plan_text: string;
287
+ plan_execution_text: string;
288
+ stats: string;
289
+ plan_json: unknown;
290
+ queryid: string;
291
+ };
292
+ expect(result.command_id).toMatch(/^\d+$/);
293
+ expect(result.status).toBe("ok");
294
+ expect(result.plan_text).toContain("Seq Scan on users");
295
+ // A >2^53 queryid survives the output rpc round-trip VERBATIM as a string.
296
+ expect(result.queryid).toBe("7712349901234567890");
297
+ // The sync contract surfaces the FULL body — execution plan and stats too.
298
+ expect(result.plan_execution_text).toContain("actual time");
299
+ expect(result.stats).toBe("Time: 12.4 ms");
300
+ expect(Array.isArray(result.plan_json)).toBe(true);
206
301
  } finally {
207
302
  api.stop();
208
303
  }
@@ -217,33 +312,66 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
217
312
  );
218
313
  expect(r.status).toBe(0);
219
314
  expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(true);
220
- const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
221
- expect(submit?.body.project_id).toBe(12);
315
+ const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
316
+ expect(run?.body.instance_id).toBe(3);
222
317
  } finally {
223
318
  api.stop();
224
319
  }
225
320
  });
226
321
 
227
- test("optimize loop: joe plan then joe exec reuse the same session on one project", async () => {
322
+ test("every verb maps to its raw Joe command text", async () => {
323
+ const cases: Array<{ argv: string[]; text: string }> = [
324
+ { argv: ["joe", "explain", "select 1", "--project", "12"], text: "explain select 1" },
325
+ { argv: ["joe", "exec", "create index on users (email)", "--project", "12"], text: "exec create index on users (email)" },
326
+ { argv: ["joe", "hypo", "create index on users (email)", "--project", "12"], text: "hypo create index on users (email)" },
327
+ { argv: ["joe", "activity", "--project", "12"], text: "activity" },
328
+ { argv: ["joe", "reset", "--project", "12"], text: "reset" },
329
+ { argv: ["joe", "terminate", "4711", "--project", "12"], text: "terminate 4711" },
330
+ { argv: ["joe", "describe", "users", "--project", "12"], text: "\\d users" },
331
+ { argv: ["joe", "describe", "users", "--project", "12", "--variant", "\\d+"], text: "\\d+ users" },
332
+ ];
333
+ for (const c of cases) {
334
+ const api = startFakeApi();
335
+ try {
336
+ const r = await runCliAsync(c.argv, isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl }));
337
+ expect(r.status).toBe(0);
338
+ const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
339
+ expect(run?.body.command).toBe(c.text);
340
+ expect(run?.body.instance_id).toBe(3);
341
+ } finally {
342
+ api.stop();
343
+ }
344
+ }
345
+ });
346
+
347
+ test("pgai joe explain --instance-id targets the instance directly (no projects_list)", async () => {
348
+ // The v1 path: projects_list is not deployed on main/.green, so the joe
349
+ // verbs must be fully usable with a manual instance id — resolution is
350
+ // skipped entirely and the id goes on the wire as the given string.
228
351
  const api = startFakeApi();
229
- const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
230
352
  try {
231
- // First command starts a fresh session; the server hands back session 88.
232
- const first = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--new-session"], env);
233
- expect(first.status).toBe(0);
234
- // Second command auto-reuses the stored session (88) on the same project.
235
- const second = await runCliAsync(["joe", "exec", "create index on users (email)", "--project", "12"], env);
236
- expect(second.status).toBe(0);
237
- expect(second.stdout).toContain("CREATE INDEX");
238
-
239
- const submits = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
240
- expect(submits.length).toBe(2);
241
- // The re-plan/exec threads the session id returned by the first submit.
242
- expect(submits[0].body.session_id).toBeNull(); // --new-session
243
- expect(submits[1].body.session_id).toBe("88");
244
- expect(submits[1].body.command).toBe("exec");
245
- // exec mints a stable idempotency key.
246
- expect(typeof submits[1].body.idempotency_key).toBe("string");
353
+ const r = await runCliAsync(
354
+ ["joe", "explain", "select 1", "--instance-id", "1"],
355
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
356
+ );
357
+ expect(r.status).toBe(0);
358
+ const run = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_run"));
359
+ expect(run?.body).toEqual({ instance_id: "1", command: "explain select 1" });
360
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/projects_list"))).toBe(false);
361
+ } finally {
362
+ api.stop();
363
+ }
364
+ });
365
+
366
+ test("exec surfaces Joe's response body", async () => {
367
+ const api = startFakeApi();
368
+ try {
369
+ const r = await runCliAsync(
370
+ ["joe", "exec", "create index on users (email)", "--project", "12"],
371
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
372
+ );
373
+ expect(r.status).toBe(0);
374
+ expect(r.stdout).toContain("CREATE INDEX");
247
375
  } finally {
248
376
  api.stop();
249
377
  }
@@ -253,153 +381,243 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
253
381
  const api = startFakeApi();
254
382
  const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
255
383
  try {
256
- // project 77 (COLD) stays `running`; --budget 0 forces immediate resume.
257
- const r = await runCliAsync(["joe", "plan", "select 1", "--project", "77", "--budget", "0"], env);
384
+ // cold-db (instance 55) stays `pending`; --budget 0 forces immediate resume.
385
+ const r = await runCliAsync(["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"], env);
258
386
  expect(r.status).toBe(0);
259
387
  expect(r.stdout).toContain("resume:");
260
388
  expect(r.stdout).toMatch(/pgai joe result \d+/);
261
389
 
262
390
  const match = r.stdout.match(/pgai joe result (\d+)/);
263
391
  const commandId = match ? match[1] : "";
264
- expect(commandId).not.toBe("");
392
+ // The id is one the fake minted ABOVE 2^53 — it must be the exact string
393
+ // the run rpc returned (a parseInt round-trip would corrupt it).
394
+ expect(commandId).toMatch(/^900719925474099\d+$/);
395
+
396
+ // `pgai joe result <id>` still returns pending for the cold instance and
397
+ // exits 1 — the output rpc must receive the id VERBATIM as a string.
398
+ const resumed = await runCliAsync(["joe", "result", commandId], env);
399
+ expect(resumed.status).toBe(1);
400
+ expect(resumed.stderr).toContain("result is not ready");
401
+ const outputReq = api.requests.filter((x) => x.pathname.endsWith("/rpc/joe_command_output")).pop();
402
+ expect(outputReq?.body.command_id).toBe(commandId);
403
+
404
+ const jsonRun = await runCliAsync(
405
+ ["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0", "--json"],
406
+ env
407
+ );
408
+ expect(jsonRun.status).toBe(0);
409
+ const jsonResume = JSON.parse(jsonRun.stdout) as {
410
+ command_id: string;
411
+ status: string;
412
+ budget_expired: boolean;
413
+ resume: string;
414
+ };
415
+ expect(jsonResume.budget_expired).toBe(true);
416
+ expect(jsonResume.status).toBe("pending");
417
+ expect(jsonResume.resume).toBe(`pgai joe result ${jsonResume.command_id}`);
418
+ } finally {
419
+ api.stop();
420
+ }
421
+ });
422
+
423
+ test("pgai joe result renders a completed command's full output", async () => {
424
+ const api = startFakeApi();
425
+ const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
426
+ try {
427
+ // Complete a command on the warm instance, then re-fetch it by id.
428
+ const run = await runCliAsync(["joe", "plan", "select 1", "--project", "12", "--json"], env);
429
+ expect(run.status).toBe(0);
430
+ const commandId = (JSON.parse(run.stdout) as { command_id: string }).command_id;
265
431
 
266
432
  const resumed = await runCliAsync(["joe", "result", commandId], env);
267
433
  expect(resumed.status).toBe(0);
268
434
  expect(resumed.stdout).toContain("Seq Scan on users");
435
+ expect(resumed.stdout).toContain("stats:");
436
+
437
+ const resumedJson = await runCliAsync(["joe", "result", commandId, "--json"], env);
438
+ expect(resumedJson.status).toBe(0);
439
+ const jsonResult = JSON.parse(resumedJson.stdout) as { status: string; plan_text: string };
440
+ expect(jsonResult.status).toBe("ok");
441
+ expect(jsonResult.plan_text).toContain("Seq Scan on users");
442
+ } finally {
443
+ api.stop();
444
+ }
445
+ });
446
+
447
+ test("pgai joe result exits 1 for a terminal error (JSON still printed with --json)", async () => {
448
+ const api = startFakeApi();
449
+ const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
450
+ try {
451
+ const human = await runCliAsync(["joe", "result", "424242"], env);
452
+ expect(human.status).toBe(1);
453
+ expect(human.stderr).toContain("ERROR: something failed");
454
+
455
+ const asJson = await runCliAsync(["joe", "result", "424242", "--json"], env);
456
+ expect(asJson.status).toBe(1);
457
+ expect((JSON.parse(asJson.stdout) as { status: string }).status).toBe("error");
458
+
459
+ const pending = await runCliAsync(["joe", "result", "424244", "--json"], env);
460
+ expect(pending.status).toBe(1);
461
+ expect((JSON.parse(pending.stdout) as { status: string }).status).toBe("pending");
269
462
  } finally {
270
463
  api.stop();
271
464
  }
272
465
  });
273
466
 
274
- test("pgai joe result exits 1 for a server-side timed_out command", async () => {
275
- // `joe result` must match the one-shot path (printJoeOutcome): a terminal
276
- // `timed_out` is a non-result — a script doing `pgai joe result $id && next`
277
- // must NOT proceed.
467
+ test("a terminal error from the one-shot exits 1 with the error message", async () => {
468
+ // A plan against the role-gated instance surfaces the PT403 detail and exits 1.
278
469
  const api = startFakeApi();
279
470
  try {
280
471
  const r = await runCliAsync(
281
- ["joe", "result", "424242"],
472
+ ["joe", "plan", "select 1", "--project", "no-role"],
282
473
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
283
474
  );
284
475
  expect(r.status).toBe(1);
285
- expect(r.stderr).toContain("timed out");
476
+ expect(`${r.stdout}\n${r.stderr}`).toContain("Joe API v2 requires the All Features role");
286
477
  } finally {
287
478
  api.stop();
288
479
  }
289
480
  });
290
481
 
291
- test("pgai joe result --json exits 1 for terminal error/timed_out (JSON still printed)", async () => {
292
- // --json keeps the machine-readable body on stdout, but the exit contract
293
- // must match the human output and the one-shot printJoeOutcome path: a
294
- // terminal failure is a non-result.
482
+ test("a command Joe fails (status=error) exits 1 with the error; --json still prints the row", async () => {
295
483
  const api = startFakeApi();
296
484
  const env = isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl });
297
485
  try {
298
- const timedOut = await runCliAsync(["joe", "result", "424242", "--json"], env);
299
- expect(timedOut.status).toBe(1);
300
- expect((JSON.parse(timedOut.stdout) as { status: string }).status).toBe("timed_out");
301
-
302
- const errored = await runCliAsync(["joe", "result", "424243", "--json"], env);
303
- expect(errored.status).toBe(1);
304
- expect((JSON.parse(errored.stdout) as { status: string }).status).toBe("error");
305
-
306
- // Non-json error path: sanitized message on stderr, exit 1.
307
- const human = await runCliAsync(["joe", "result", "424243"], env);
486
+ const human = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db"], env);
308
487
  expect(human.status).toBe(1);
309
- expect(human.stderr).toContain("Query error");
488
+ expect(human.stderr).toContain('ERROR: relation "nope" does not exist');
489
+
490
+ const asJson = await runCliAsync(["joe", "plan", "select * from nope", "--project", "err-db", "--json"], env);
491
+ expect(asJson.status).toBe(1);
492
+ const row = JSON.parse(asJson.stdout) as { status: string; error: string };
493
+ expect(row.status).toBe("error");
494
+ expect(row.error).toContain("does not exist");
310
495
  } finally {
311
496
  api.stop();
312
497
  }
313
498
  });
314
499
 
315
- test("budget-expiry resume hint reports the ACTUAL --budget, not the hardcoded default", async () => {
500
+ test("a 502-ing output endpoint surfaces the resume handle instead of losing the id", async () => {
501
+ // The run rpc succeeded (valid command id); every output poll 502s. The
502
+ // one-shot must NOT throw the id away — it exits 0 with the resume line,
503
+ // and the id is the exact string the run rpc returned.
316
504
  const api = startFakeApi();
317
505
  try {
318
- // Project 77 (COLD) stays `running`; --budget 0 expires immediately. The
319
- // hint must say "budget 0s reached" — not the DEFAULT_BUDGET_MS 25s.
320
506
  const r = await runCliAsync(
321
- ["joe", "plan", "select 1", "--project", "77", "--budget", "0"],
507
+ ["joe", "plan", "select 1", "--project", "flaky-db", "--budget", "0.2"],
322
508
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
323
509
  );
324
510
  expect(r.status).toBe(0);
325
- expect(r.stdout).toContain("budget 0s reached");
326
- expect(r.stdout).not.toContain("budget 25s reached");
511
+ expect(r.stdout).toContain("resume:");
512
+ const match = r.stdout.match(/pgai joe result (\d+)/);
513
+ expect(match?.[1]).toMatch(/^900719925474099\d+$/);
514
+ // The run rpc really was hit and the polls really did 502.
515
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(true);
516
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_output"))).toBe(true);
327
517
  } finally {
328
518
  api.stop();
329
519
  }
330
520
  });
331
521
 
332
- test("ai_enabled=false submit is refused (PT403) with a clear message and exit 1", async () => {
522
+ test("a terminal 404 on joe result aborts with a legible message", async () => {
333
523
  const api = startFakeApi();
334
524
  try {
335
525
  const r = await runCliAsync(
336
- ["joe", "plan", "select 1", "--project", "999"],
526
+ ["joe", "result", "111"],
337
527
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
338
528
  );
339
529
  expect(r.status).toBe(1);
340
- expect(`${r.stdout}\n${r.stderr}`).toContain("AI features disabled for this org");
530
+ expect(r.stderr).toContain("Failed to fetch command output");
531
+ expect(r.stderr).toContain("404");
341
532
  } finally {
342
533
  api.stop();
343
534
  }
344
535
  });
345
536
 
346
- test("joe terminate requires a numeric pid and maps args.pid", async () => {
537
+ test("budget-expiry resume hint reports the ACTUAL --budget, not the hardcoded default", async () => {
347
538
  const api = startFakeApi();
348
539
  try {
349
540
  const r = await runCliAsync(
350
- ["joe", "terminate", "4711", "--project", "12"],
541
+ ["joe", "plan", "select 1", "--project", "cold-db", "--budget", "0"],
351
542
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
352
543
  );
353
544
  expect(r.status).toBe(0);
354
- const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
355
- expect(submit?.body.command).toBe("terminate");
356
- expect(submit?.body.args).toEqual({ pid: 4711 });
357
- expect(submit?.body.sql).toBeNull();
545
+ expect(r.stdout).toContain("budget 0s reached");
546
+ expect(r.stdout).not.toContain("budget 25s reached");
358
547
  } finally {
359
548
  api.stop();
360
549
  }
361
550
  });
362
551
 
363
- test("joe terminate rejects a non-numeric / trailing-garbage pid (no submit)", async () => {
364
- // A pid must be a bare non-negative integer. parseInt() would silently accept
365
- // "12x"→12, "-5"→-5, "1.5"→1 and submit a WRONG pg_terminate_backend target.
366
- // Those must be a clean, typed rejection that never reaches the submit rpc.
552
+ test("negative --budget is rejected before any network call", async () => {
367
553
  const api = startFakeApi();
368
554
  try {
369
- for (const bad of ["12x", "abc", "1.5", "0x10", " "]) {
555
+ const r = await runCliAsync(
556
+ ["joe", "plan", "select 1", "--project", "12", "--budget", "-5"],
557
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
558
+ );
559
+ expect(r.status).toBe(1);
560
+ expect(r.stderr).toContain("--budget must be a non-negative number");
561
+ expect(api.requests.length).toBe(0);
562
+ } finally {
563
+ api.stop();
564
+ }
565
+ });
566
+
567
+ test("joe terminate rejects a non-numeric / trailing-garbage pid (no network at all)", async () => {
568
+ // A pid must be a bare positive integer. parseInt() would silently accept
569
+ // "12x"→12, "-5"→-5, "1.5"→1 and terminate a WRONG backend. Those must be
570
+ // a clean, typed rejection that never reaches any rpc.
571
+ const api = startFakeApi();
572
+ try {
573
+ for (const bad of ["0", "12x", "abc", "1.5", "0x10", " "]) {
370
574
  const r = await runCliAsync(
371
575
  ["joe", "terminate", bad, "--project", "12"],
372
576
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
373
577
  );
374
578
  expect(r.status).toBe(1);
375
- expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a number");
579
+ expect(`${r.stdout}\n${r.stderr}`).toContain("pid must be a positive integer");
376
580
  }
377
- expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_submit"))).toBe(false);
581
+ expect(api.requests.length).toBe(0);
378
582
  } finally {
379
583
  api.stop();
380
584
  }
381
585
  });
382
586
 
383
- test("joe describe maps args.object (and --variant)", async () => {
587
+ test("joe describe rejects a variant outside Joe's psql allowlist", async () => {
384
588
  const api = startFakeApi();
385
589
  try {
386
590
  const r = await runCliAsync(
387
- ["joe", "describe", "users", "--project", "12", "--variant", "\\d+"],
591
+ ["joe", "describe", "users", "--project", "12", "--variant", "\\dx"],
388
592
  isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
389
593
  );
390
- expect(r.status).toBe(0);
391
- const submit = api.requests.find((x) => x.pathname.endsWith("/rpc/joe_command_submit"));
392
- expect(submit?.body.command).toBe("describe");
393
- expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
594
+ expect(r.status).toBe(1);
595
+ expect(`${r.stdout}\n${r.stderr}`).toContain("Unsupported describe variant");
596
+ expect(api.requests.length).toBe(0);
394
597
  } finally {
395
598
  api.stop();
396
599
  }
397
600
  });
398
601
 
399
- test("missing --project fails fast", () => {
602
+ test("a project without a Joe instance fails with a clear message", async () => {
603
+ const api = startFakeApi();
604
+ try {
605
+ const r = await runCliAsync(
606
+ ["joe", "plan", "select 1", "--project", "no-joe"],
607
+ isolatedEnv({ PGAI_API_KEY: "k", PGAI_API_BASE_URL: api.baseUrl })
608
+ );
609
+ expect(r.status).toBe(1);
610
+ expect(`${r.stdout}\n${r.stderr}`).toContain("has no Joe instance");
611
+ expect(api.requests.some((x) => x.pathname.endsWith("/rpc/joe_command_run"))).toBe(false);
612
+ } finally {
613
+ api.stop();
614
+ }
615
+ });
616
+
617
+ test("neither --project nor --instance-id fails fast with the v1 hint", () => {
400
618
  const r = runCli(["joe", "plan", "select 1"], isolatedEnv({ PGAI_API_KEY: "k" }));
401
619
  expect(r.status).toBe(1);
402
- expect(`${r.stdout}\n${r.stderr}`).toContain("Project is required");
620
+ expect(`${r.stdout}\n${r.stderr}`).toContain("--instance-id");
403
621
  });
404
622
 
405
623
  test("missing API key fails fast", () => {
@@ -408,62 +626,3 @@ describe("CLI Joe command surface (grouped under `pgai joe …`)", () => {
408
626
  expect(`${r.stdout}\n${r.stderr}`).toContain("API key is required");
409
627
  });
410
628
  });
411
-
412
- // ---------------------------------------------------------------------------
413
- // inferCommandFromResult — the sole logic mapping a bare `joe_command_result`
414
- // body back to a command for `pgai joe result <id>` rendering. Table-driven
415
- // over every branch of the per-command contract matrix (CONTRACT_DECISIONS):
416
- // plan/explain {queryid, plan_fingerprint, plan_text, plan_json} · exec
417
- // {command, row_count, result_rows, notices} · hypo {hypo_plan, hypo_used} ·
418
- // activity/describe {snapshot} · terminate {terminated, pid} · reset {reset}.
419
- // The `!== undefined` chain is order-sensitive, so each contract shape is
420
- // pinned; per the contract every command's keys are disjoint (a hypo re-plan
421
- // rides in `hypo_plan`, never `plan_text`/`plan_json`), so `hypo_used` being
422
- // checked after the plan keys cannot misclassify a contract-shaped body.
423
- // ---------------------------------------------------------------------------
424
-
425
- describe("inferCommandFromResult (joe result <id> body → command)", () => {
426
- const cases: Array<{ name: string; body: Parameters<typeof inferCommandFromResult>[0]; expected: ReturnType<typeof inferCommandFromResult> }> = [
427
- // The `command` echo (DECISION: every result echoes `command`) short-circuits
428
- // everything — even when overlapping keys are present.
429
- {
430
- name: "command echo short-circuits (exec despite plan keys present)",
431
- body: { command: "exec", plan_text: "Seq Scan", plan_json: {}, row_count: 1, result_rows: [{ n: 1 }] },
432
- expected: "exec",
433
- },
434
- { name: "command echo: describe (snapshot would otherwise infer activity)", body: { command: "describe", snapshot: {} }, expected: "describe" },
435
- // plan / explain — either plan key suffices; null still counts (key present).
436
- { name: "plan_text + plan_json → plan", body: { plan_text: "Index Scan", plan_json: { Plan: {} } }, expected: "plan" },
437
- { name: "plan_text only → plan", body: { plan_text: "Index Scan" }, expected: "plan" },
438
- { name: "plan_json only → plan", body: { plan_json: { Plan: {} } }, expected: "plan" },
439
- { name: "null plan_text (key present) → plan", body: { plan_text: null }, expected: "plan" },
440
- // exec — row_count / result_rows.
441
- { name: "row_count + result_rows → exec", body: { row_count: 2, result_rows: [{ a: 1 }, { a: 2 }] }, expected: "exec" },
442
- { name: "result_rows only → exec", body: { result_rows: [] }, expected: "exec" },
443
- { name: "row_count 0 (falsy but present) → exec", body: { row_count: 0 }, expected: "exec" },
444
- // hypo — the contract body is {hypo_plan, hypo_used}; hypo_used:false must
445
- // still classify (the "would NOT be used" verdict).
446
- { name: "hypo_used true + hypo_plan → hypo", body: { hypo_used: true, hypo_plan: { Plan: {} } }, expected: "hypo" },
447
- { name: "hypo_used false → hypo", body: { hypo_used: false, hypo_plan: { Plan: {} } }, expected: "hypo" },
448
- // terminate — terminated/pid; a false verdict still classifies.
449
- { name: "terminated true → terminate", body: { terminated: true, pid: 66 }, expected: "terminate" },
450
- { name: "terminated false → terminate", body: { terminated: false, pid: 66 }, expected: "terminate" },
451
- // reset.
452
- { name: "reset true → reset", body: { reset: true }, expected: "reset" },
453
- { name: "reset false → reset", body: { reset: false }, expected: "reset" },
454
- // activity / describe — both ride `snapshot`; without the `command` echo the
455
- // inference picks "activity" (rendering is identical for the two).
456
- { name: "snapshot → activity", body: { snapshot: [{ pid: 1 }] }, expected: "activity" },
457
- // Unrecognized.
458
- { name: "empty body → null", body: {}, expected: null },
459
- // Only `hypo_used` classifies hypo (always present per contract); a lone
460
- // hypo_plan — a contract-violating body — stays unrecognized, not "plan".
461
- { name: "hypo_plan without hypo_used → null (not misclassified as plan)", body: { hypo_plan: {} }, expected: null },
462
- ];
463
-
464
- for (const c of cases) {
465
- test(c.name, () => {
466
- expect(inferCommandFromResult(c.body)).toBe(c.expected);
467
- });
468
- }
469
- });