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/test/joe.test.ts CHANGED
@@ -1,28 +1,21 @@
1
1
  import { describe, test, expect, mock, afterEach, spyOn } from "bun:test";
2
- import { mkdtempSync, existsSync, readFileSync } from "fs";
3
- import { tmpdir } from "os";
4
- import { resolve } from "path";
5
2
  import {
6
- submitCommand,
7
- getCommandStatus,
8
- getCommandResult,
3
+ startCommand,
4
+ getCommandOutput,
9
5
  listProjects,
10
- resolveProjectId,
6
+ resolveJoeInstanceId,
11
7
  isNumericProjectRef,
12
- computeIdempotencyKey,
8
+ buildJoeCommandText,
13
9
  runCommand,
14
10
  executeJoeCommand,
15
- readStoredSessionId,
16
- writeStoredSessionId,
17
- clearStoredSessionId,
18
11
  clientSidePlanFlags,
19
12
  formatProjectsTable,
20
- formatJoeResult,
13
+ formatJoeOutput,
21
14
  JOE_COMMANDS,
15
+ DESCRIBE_VARIANTS,
22
16
  type JoeCommand,
23
- type JoeStatus,
17
+ type JoeCommandOutput,
24
18
  } from "../lib/joe";
25
- import { handleToolCall, joeToolDefinitions, sanitizeBudgetMs, JOE_TOOL_TO_COMMAND, type McpToolRequest } from "../lib/mcp-server";
26
19
 
27
20
  const BASE = "https://api.example.com";
28
21
  const originalFetch = globalThis.fetch;
@@ -54,136 +47,203 @@ function json(obj: unknown, status = 200): Response {
54
47
  return new Response(JSON.stringify(obj), { status, headers: { "Content-Type": "application/json" } });
55
48
  }
56
49
 
57
- function tempDir(): string {
58
- return mkdtempSync(resolve(tmpdir(), "joe-test-"));
59
- }
60
-
61
50
  afterEach(() => {
62
51
  globalThis.fetch = originalFetch;
63
52
  });
64
53
 
65
- describe("submitCommand", () => {
54
+ describe("startCommand (joe_command_run)", () => {
66
55
  test("throws when apiKey missing", async () => {
67
56
  await expect(
68
- submitCommand({ apiKey: "", apiBaseUrl: BASE, command: "plan", projectId: 12 })
57
+ startCommand({ apiKey: "", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
69
58
  ).rejects.toThrow("API key is required");
70
59
  });
71
60
 
72
- test("maps to /rpc/joe_command_submit with the correct payload", async () => {
73
- const captured = installFetch({
74
- joe_command_submit: () => json({ command_id: "4711", session_id: "88", status: "queued" }),
75
- });
76
- const res = await submitCommand({
61
+ test("throws on empty command text before any network call", async () => {
62
+ const captured = installFetch({ joe_command_run: () => json("1") });
63
+ await expect(
64
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: " " })
65
+ ).rejects.toThrow("command text is required");
66
+ expect(captured.length).toBe(0);
67
+ });
68
+
69
+ test("maps to /rpc/joe_command_run with {instance_id, command} and the access-token header", async () => {
70
+ const captured = installFetch({ joe_command_run: () => json("4711") });
71
+ const id = await startCommand({
77
72
  apiKey: "k",
78
73
  apiBaseUrl: BASE,
79
- command: "plan",
80
- projectId: 12,
81
- sql: "select 1",
82
- sessionId: "88",
83
- idempotencyKey: "idem-1",
74
+ instanceId: 3,
75
+ command: "plan select 1",
84
76
  });
85
- expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_submit`);
77
+ expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_run`);
86
78
  expect(captured[0].headers["access-token"]).toBe("k");
87
- expect(captured[0].body).toEqual({
88
- project_id: 12,
89
- command: "plan",
90
- sql: "select 1",
91
- args: null,
92
- session_id: "88",
93
- idempotency_key: "idem-1",
94
- });
95
- expect(res).toEqual({ command_id: "4711", session_id: "88", status: "queued" });
79
+ // The RAW command text goes on the wire — no structured body, no prefixing.
80
+ expect(captured[0].body).toEqual({ instance_id: 3, command: "plan select 1" });
81
+ expect(id).toBe("4711");
96
82
  });
97
83
 
98
- test("sends null sql/session_id and args for structured commands", async () => {
99
- const captured = installFetch({
100
- joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" }),
101
- });
102
- await submitCommand({
103
- apiKey: "k",
104
- apiBaseUrl: BASE,
105
- command: "terminate",
106
- projectId: 5,
107
- args: { pid: 4711 },
108
- idempotencyKey: "x",
109
- });
110
- expect(captured[0].body.sql).toBeNull();
111
- expect(captured[0].body.session_id).toBeNull();
112
- expect(captured[0].body.args).toEqual({ pid: 4711 });
84
+ test("the command id STAYS a string a >2^53 id survives verbatim", async () => {
85
+ // joe_command_run returns to_json(id::text). Any parseInt/Number round-trip
86
+ // would corrupt 9007199254740993 to 9007199254740992.
87
+ installFetch({ joe_command_run: () => json("9007199254740993") });
88
+ const id = await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" });
89
+ expect(id).toBe("9007199254740993");
90
+ expect(typeof id).toBe("string");
113
91
  });
114
92
 
115
- test("surfaces the ai_enabled=false PT403 disabled-AI message", async () => {
93
+ test("a non-string rpc reply is rejected (contract violation)", async () => {
94
+ installFetch({ joe_command_run: () => json({ command_id: "1" }) });
95
+ await expect(
96
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" })
97
+ ).rejects.toThrow(/expected a command id string/);
98
+ });
99
+
100
+ test("a bare-number id is rejected — the whole point of the precision guard", async () => {
101
+ // If the backend ever returned to_json(id) instead of to_json(id::text),
102
+ // JSON.parse would have ALREADY rounded 9007199254740993 → ...992; a
103
+ // number must never be silently String()ed back.
104
+ installFetch({ joe_command_run: () => json(9007199254740993) });
105
+ await expect(
106
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "reset" })
107
+ ).rejects.toThrow(/expected a command id string/);
108
+ });
109
+
110
+ test("a 401 rejects with the auth remediation hint", async () => {
111
+ installFetch({ joe_command_run: () => json({ message: "JWT expired" }, 401) });
112
+ let thrown: Error | null = null;
113
+ try {
114
+ await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" });
115
+ } catch (err) {
116
+ thrown = err as Error;
117
+ }
118
+ expect(thrown?.message).toContain("HTTP 401");
119
+ expect(thrown?.message).toContain("JWT expired");
120
+ expect(thrown?.message).toContain("postgresai auth");
121
+ });
122
+
123
+ test("a 502 on run rejects legibly (no id exists yet — nothing to resume)", async () => {
124
+ installFetch({ joe_command_run: () => new Response("Bad Gateway", { status: 502 }) });
125
+ await expect(
126
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
127
+ ).rejects.toThrow(/Failed to run Joe command: HTTP 502/);
128
+ });
129
+
130
+ test("surfaces the missing-role PT403 detail from the JSON body", async () => {
116
131
  installFetch({
117
- joe_command_submit: () =>
118
- json({ message: "AI features disabled for this org enable in AI Assistant Settings" }, 403),
132
+ joe_command_run: () =>
133
+ json({ code: "PT403", message: "Forbidden", details: "Joe API v2 requires the All Features role." }, 403),
119
134
  });
120
135
  await expect(
121
- submitCommand({ apiKey: "k", apiBaseUrl: BASE, command: "plan", projectId: 12, sql: "select 1" })
122
- ).rejects.toThrow(/AI features disabled for this org/);
136
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
137
+ ).rejects.toThrow(/Joe API v2 requires the All Features role/);
123
138
  });
124
139
 
125
- test("surfaces a scope refusal (PT403)", async () => {
140
+ test("surfaces a PT403 reason phrase when PostgREST returns no JSON body", async () => {
126
141
  installFetch({
127
- joe_command_submit: () => json({ message: "missing scope joe:exec" }, 403),
142
+ joe_command_run: () => new Response(null, {
143
+ status: 403,
144
+ statusText: "Joe API v2 requires the All Features role.",
145
+ }),
128
146
  });
129
147
  await expect(
130
- submitCommand({ apiKey: "k", apiBaseUrl: BASE, command: "exec", projectId: 12, sql: "create index" })
131
- ).rejects.toThrow(/missing scope joe:exec/);
148
+ startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" })
149
+ ).rejects.toThrow("Joe API v2 requires the All Features role.");
132
150
  });
133
- });
134
151
 
135
- describe("getCommandStatus / getCommandResult", () => {
136
- test("status maps to /rpc/joe_command_status with command_id", async () => {
137
- const captured = installFetch({
138
- joe_command_status: () => json({ command_id: "4711", status: "running", error: null }),
152
+ test("a non-JSON body embedded in the parse-failure error is redacted", async () => {
153
+ // The thrown Error flows into CLI stderr a body echoing credentials must
154
+ // not bypass redaction on this path.
155
+ installFetch({
156
+ joe_command_run: () => new Response("oops password=hunter2 dsn=postgresql://joe:pw-abc@h/db", { status: 200 }),
139
157
  });
140
- const res = await getCommandStatus({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
141
- expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_status`);
142
- expect(captured[0].body).toEqual({ command_id: "4711" });
143
- expect(res.status).toBe("running");
158
+ let thrown: Error | null = null;
159
+ try {
160
+ await startCommand({ apiKey: "k", apiBaseUrl: BASE, instanceId: 3, command: "plan select 1" });
161
+ } catch (err) {
162
+ thrown = err as Error;
163
+ }
164
+ expect(thrown?.message).toContain("failed to parse response");
165
+ expect(thrown?.message).not.toContain("hunter2");
166
+ expect(thrown?.message).not.toContain("pw-abc");
144
167
  });
168
+ });
145
169
 
146
- test("result maps to /rpc/joe_command_result and returns the plan body", async () => {
170
+ describe("getCommandOutput (joe_command_output)", () => {
171
+ test("maps to /rpc/joe_command_output with {command_id} and returns the FULL body", async () => {
147
172
  const captured = installFetch({
148
- joe_command_result: () =>
173
+ joe_command_output: () =>
149
174
  json({
150
175
  command_id: "4711",
151
- status: "done",
152
- queryid: "77",
153
- plan_fingerprint: "a1b2",
176
+ status: "ok",
177
+ created_at: "2026-07-22T10:00:00",
178
+ command: "explain",
179
+ query: "select * from users",
180
+ queryid: "7712349901234567890",
181
+ response: null,
154
182
  plan_text: "Seq Scan on users",
155
- plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
183
+ plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
184
+ plan_execution_text: "Seq Scan on users (actual time=0.1..12.3)",
185
+ plan_execution_json: [{ Plan: {} }],
186
+ stats: "Time: 12.4 ms",
187
+ recommendations: ":warning: Seq Scan",
156
188
  error: null,
157
189
  }),
158
190
  });
159
- const res = await getCommandResult({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
160
- expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_result`);
161
- expect(res.plan_text).toBe("Seq Scan on users");
191
+ const output = await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
192
+ expect(captured[0].url).toBe(`${BASE}/rpc/joe_command_output`);
193
+ expect(captured[0].body).toEqual({ command_id: "4711" });
194
+ expect(output.status).toBe("ok");
195
+ expect(output.plan_text).toBe("Seq Scan on users");
196
+ expect(output.plan_execution_text).toContain("actual time");
197
+ expect(output.stats).toBe("Time: 12.4 ms");
198
+ // plan_json arrives structured (the rpc unwraps the stored jsonb string).
199
+ expect(Array.isArray(output.plan_json)).toBe(true);
200
+ });
201
+
202
+ test("a 'pending' body (no result columns yet) passes through", async () => {
203
+ installFetch({
204
+ joe_command_output: () => json({ command_id: "4711", status: "pending", created_at: "2026-07-22T10:00:00" }),
205
+ });
206
+ const output = await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "4711" });
207
+ expect(output.status).toBe("pending");
208
+ expect(output.plan_text).toBeUndefined();
162
209
  });
163
210
 
164
- test("PT404 (not found / out-of-allowlist) surfaces as an error", async () => {
211
+ test("PT404 (not found / other org) surfaces as an error", async () => {
165
212
  installFetch({
166
- joe_command_status: () => json({ message: "command not found" }, 404),
213
+ joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
167
214
  });
168
215
  await expect(
169
- getCommandStatus({ apiKey: "k", apiBaseUrl: BASE, commandId: "9999" })
170
- ).rejects.toThrow(/Failed to fetch command status/);
216
+ getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "9999" })
217
+ ).rejects.toThrow(/Failed to fetch command output/);
218
+ });
219
+
220
+ test("requires a commandId", async () => {
221
+ await expect(
222
+ getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "" })
223
+ ).rejects.toThrow("commandId is required");
224
+ });
225
+
226
+ test("a 502 rejects legibly (never mistaken for success)", async () => {
227
+ installFetch({ joe_command_output: () => new Response("<html>Bad Gateway</html>", { status: 502 }) });
228
+ await expect(
229
+ getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "1" })
230
+ ).rejects.toThrow(/Failed to fetch command output: HTTP 502/);
171
231
  });
172
232
  });
173
233
 
174
234
  describe("listProjects", () => {
175
- test("normalizes id/joe_api_v2_enabled/tunnel variants", async () => {
235
+ test("normalizes rows and defaults missing optional fields", async () => {
176
236
  installFetch({
177
237
  projects_list: () =>
178
238
  json([
179
- { id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_api_v2_enabled: true, tunnel_ready: true, instance_id: 3, dblab_instance_id: 7 },
239
+ { project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
180
240
  { project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false },
181
241
  ]),
182
242
  });
183
243
  const projects = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
184
244
  expect(projects).toEqual([
185
- { project_id: 12, alias: "main-db", name: "Main DB", label: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
186
- { project_id: 13, alias: "dw", name: "Warehouse", label: null, joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
245
+ { project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
246
+ { project_id: 13, alias: "dw", name: "Warehouse", joe_ready: false, tunnel: false, instance_id: null, dblab_instance_id: null },
187
247
  ]);
188
248
  });
189
249
 
@@ -192,9 +252,29 @@ describe("listProjects", () => {
192
252
  await listProjects({ apiKey: "k", apiBaseUrl: BASE, orgId: 7 });
193
253
  expect(captured[0].body).toEqual({ org_id: 7 });
194
254
  });
255
+
256
+ test("preserves 64-bit project and instance ids without Number precision loss", async () => {
257
+ installFetch({
258
+ projects_list: () => json([{
259
+ project_id: "9007199254740993",
260
+ alias: "huge",
261
+ instance_id: "9007199254740994",
262
+ dblab_instance_id: "9007199254740995",
263
+ }]),
264
+ });
265
+ const [project] = await listProjects({ apiKey: "k", apiBaseUrl: BASE });
266
+ expect(project.project_id).toBe("9007199254740993");
267
+ expect(project.instance_id).toBe("9007199254740994");
268
+ expect(project.dblab_instance_id).toBe("9007199254740995");
269
+ });
195
270
  });
196
271
 
197
- describe("resolveProjectId (id-or-alias)", () => {
272
+ describe("resolveJoeInstanceId (project id-or-alias → Joe instance)", () => {
273
+ const PROJECTS = [
274
+ { project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, instance_id: 3 },
275
+ { project_id: 13, alias: "no-joe", name: "No Joe", joe_ready: false, instance_id: null },
276
+ ];
277
+
198
278
  test("isNumericProjectRef distinguishes ids from aliases", () => {
199
279
  expect(isNumericProjectRef("12")).toBe(true);
200
280
  expect(isNumericProjectRef(" 12 ")).toBe(true);
@@ -202,329 +282,459 @@ describe("resolveProjectId (id-or-alias)", () => {
202
282
  expect(isNumericProjectRef("12a")).toBe(false);
203
283
  });
204
284
 
205
- test("numeric ref resolves WITHOUT a projects lookup", async () => {
206
- const captured = installFetch({ projects_list: () => json([]) });
207
- const id = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "12" });
208
- expect(id).toBe(12);
209
- expect(captured.length).toBe(0);
285
+ test("a numeric project id resolves to the project's instance_id (lookup required)", async () => {
286
+ // Unlike a pure project-id resolver, the instance id only lives in the
287
+ // projects listing a numeric ref must still hit projects_list.
288
+ const captured = installFetch({ projects_list: () => json(PROJECTS) });
289
+ const id = await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "12" });
290
+ expect(id).toBe(3);
291
+ expect(captured.length).toBe(1);
210
292
  });
211
293
 
212
- test("alias resolves via the projects list (case-insensitive)", async () => {
213
- installFetch({
214
- projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true }]),
215
- });
216
- const id = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "MAIN-DB" });
217
- expect(id).toBe(12);
294
+ test("alias / name resolve case-insensitively to the same instance", async () => {
295
+ installFetch({ projects_list: () => json(PROJECTS) });
296
+ expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "MAIN-DB" })).toBe(3);
297
+ expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "main db" })).toBe(3);
218
298
  });
219
299
 
220
- test("--project 12 --project main-db", async () => {
300
+ test("a 64-bit instance id survives as a string", async () => {
221
301
  installFetch({
222
- projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true }]),
302
+ projects_list: () => json([{ project_id: 12, alias: "huge", instance_id: "9007199254740994" }]),
223
303
  });
224
- const byId = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "12" });
225
- const byAlias = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "main-db" });
226
- expect(byId).toBe(byAlias);
304
+ expect(await resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "huge" }))
305
+ .toBe("9007199254740994");
227
306
  });
228
307
 
229
- test("unknown alias throws", async () => {
230
- installFetch({ projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB" }]) });
308
+ test("unknown ref throws with a 'pgai projects' hint", async () => {
309
+ installFetch({ projects_list: () => json(PROJECTS) });
231
310
  await expect(
232
- resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "nope" })
233
- ).rejects.toThrow(/Project not found for alias\/name 'nope'/);
311
+ resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "nope" })
312
+ ).rejects.toThrow(/Project not found for id\/alias\/name 'nope'/);
234
313
  });
235
314
 
236
- test("label resolves via the projects list — same reference set as the DBLab resolver", async () => {
237
- // dblab.ts `resolveDblabInstanceId` matches alias/name/LABEL; the Joe resolver
238
- // must accept the same reference set, or `--project <label>` works for dblab
239
- // verbs but fails for joe verbs on the very same project.
240
- installFetch({
241
- projects_list: () =>
242
- json([{ id: 31, alias: "prod-db", name: "Prod", label: "Production EU", joe_ready: true }]),
243
- });
244
- const id = await resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "production eu" });
245
- expect(id).toBe(31);
315
+ test("a project without a Joe instance throws", async () => {
316
+ installFetch({ projects_list: () => json(PROJECTS) });
317
+ await expect(
318
+ resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "no-joe" })
319
+ ).rejects.toThrow(/has no Joe instance/);
246
320
  });
247
321
 
248
- test("empty project ref throws", async () => {
322
+ test("empty project ref throws before any network call", async () => {
323
+ const captured = installFetch({ projects_list: () => json(PROJECTS) });
249
324
  await expect(
250
- resolveProjectId({ apiKey: "k", apiBaseUrl: BASE, project: "" })
325
+ resolveJoeInstanceId({ apiKey: "k", apiBaseUrl: BASE, project: "" })
251
326
  ).rejects.toThrow(/project is required/);
327
+ expect(captured.length).toBe(0);
252
328
  });
253
329
  });
254
330
 
255
- describe("computeIdempotencyKey", () => {
256
- test("exec/explain keys are STABLE (deterministic) across calls", () => {
257
- const a = computeIdempotencyKey("exec", 12, "create index on users (email)", null);
258
- const b = computeIdempotencyKey("exec", 12, "create index on users (email)", null);
259
- expect(a).toBe(b);
260
- // A different statement diverges.
261
- const c = computeIdempotencyKey("exec", 12, "create index on orders (id)", null);
262
- expect(a).not.toBe(c);
331
+ describe("buildJoeCommandText — the raw wire text per verb", () => {
332
+ test("SQL-carrying verbs prefix the verb, nothing else", () => {
333
+ expect(buildJoeCommandText("plan", { arg: "select * from users" })).toBe("plan select * from users");
334
+ expect(buildJoeCommandText("explain", { arg: "select 1" })).toBe("explain select 1");
335
+ expect(buildJoeCommandText("exec", { arg: "create index on users (email)" })).toBe("exec create index on users (email)");
336
+ expect(buildJoeCommandText("hypo", { arg: "create index on users (email)" })).toBe("hypo create index on users (email)");
337
+ });
338
+
339
+ test("bare verbs render as the verb alone", () => {
340
+ expect(buildJoeCommandText("activity")).toBe("activity");
341
+ expect(buildJoeCommandText("reset")).toBe("reset");
342
+ });
343
+
344
+ test("terminate renders `terminate <pid>` and rejects non-bare-digit pids", () => {
345
+ expect(buildJoeCommandText("terminate", { arg: "4711" })).toBe("terminate 4711");
346
+ for (const bad of ["0", "12x", "abc", "1.5", "0x10", "-5", "", " "]) {
347
+ expect(() => buildJoeCommandText("terminate", { arg: bad })).toThrow("pid must be a positive integer");
348
+ }
349
+ });
350
+
351
+ test("describe defaults to \\d and honors an allowlisted --variant", () => {
352
+ expect(buildJoeCommandText("describe", { arg: "users" })).toBe("\\d users");
353
+ expect(buildJoeCommandText("describe", { arg: "users", variant: "\\d+" })).toBe("\\d+ users");
354
+ expect(buildJoeCommandText("describe", { arg: "users_pkey", variant: "\\di+" })).toBe("\\di+ users_pkey");
263
355
  });
264
356
 
265
- test("plan keys are fresh per invocation", () => {
266
- const a = computeIdempotencyKey("plan", 12, "select 1", null);
267
- const b = computeIdempotencyKey("plan", 12, "select 1", null);
268
- expect(a).not.toBe(b);
357
+ test("describe rejects a variant outside Joe's psql allowlist", () => {
358
+ for (const bad of ["\\dx", "\\copy", "d", "\\du"]) {
359
+ expect(() => buildJoeCommandText("describe", { arg: "users", variant: bad })).toThrow(/Unsupported describe variant/);
360
+ }
361
+ // The allowlist mirrors Joe's own dispatcher table.
362
+ expect([...DESCRIBE_VARIANTS]).toEqual(["\\d", "\\d+", "\\dt", "\\dt+", "\\di", "\\di+", "\\l", "\\l+", "\\dv", "\\dv+", "\\dm", "\\dm+"]);
363
+ });
364
+
365
+ test("empty required arguments throw", () => {
366
+ for (const verb of ["plan", "explain", "exec", "hypo"] as const) {
367
+ expect(() => buildJoeCommandText(verb, { arg: " " })).toThrow(`${verb} requires an argument`);
368
+ }
369
+ expect(() => buildJoeCommandText("describe", { arg: "" })).toThrow("describe requires an object name");
370
+ });
371
+
372
+ test("JOE_COMMANDS is the Joe dispatcher verb set", () => {
373
+ const expected: JoeCommand[] = ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe"];
374
+ expect([...JOE_COMMANDS]).toEqual(expected);
269
375
  });
270
376
  });
271
377
 
272
- describe("runCommand — submit-then-poll", () => {
273
- test("polls until terminal then fetches the result", async () => {
274
- let statusCalls = 0;
275
- installFetch({
276
- joe_command_submit: () => json({ command_id: "4711", session_id: "88", status: "queued" }),
277
- joe_command_status: () => {
278
- statusCalls += 1;
279
- return json({ command_id: "4711", status: statusCalls < 2 ? "running" : "done", error: null });
378
+ describe("runCommand — run-then-poll", () => {
379
+ test("polls the output until terminal (pending pending → ok)", async () => {
380
+ let outputCalls = 0;
381
+ const captured = installFetch({
382
+ joe_command_run: () => json("4711"),
383
+ joe_command_output: () => {
384
+ outputCalls += 1;
385
+ if (outputCalls < 3) {
386
+ return json({ command_id: "4711", status: "pending" });
387
+ }
388
+ return json({ command_id: "4711", status: "ok", plan_text: "Index Scan", error: null });
280
389
  },
281
- joe_command_result: () =>
282
- json({ command_id: "4711", status: "done", plan_text: "Index Scan", plan_json: {}, error: null }),
283
390
  });
284
391
  const outcome = await runCommand({
285
392
  apiKey: "k",
286
393
  apiBaseUrl: BASE,
287
- command: "plan",
288
- projectId: 12,
289
- sql: "select 1",
394
+ instanceId: 3,
395
+ command: "plan select 1",
290
396
  pollIntervalMs: 0,
291
397
  sleep: async () => {},
292
398
  });
293
- expect(outcome.status).toBe("done");
399
+ expect(outcome.status).toBe("ok");
294
400
  expect(outcome.budgetExpired).toBe(false);
295
401
  expect(outcome.commandId).toBe("4711");
296
- expect(outcome.sessionId).toBe("88");
297
- expect(outcome.result?.plan_text).toBe("Index Scan");
298
- expect(statusCalls).toBeGreaterThanOrEqual(2);
402
+ expect(outcome.output?.plan_text).toBe("Index Scan");
403
+ expect(outputCalls).toBe(3);
404
+ // Every poll carried the id as the string the run rpc returned.
405
+ for (const call of captured.slice(1)) {
406
+ expect(call.body).toEqual({ command_id: "4711" });
407
+ }
299
408
  });
300
409
 
301
- test("budget expiry returns a resume handle without a result", async () => {
410
+ test("budget expiry returns a resume handle without an output", async () => {
302
411
  installFetch({
303
- joe_command_submit: () => json({ command_id: "4712", session_id: "88", status: "queued" }),
304
- joe_command_status: () => json({ command_id: "4712", status: "running", error: null }),
305
- joe_command_result: () => json({ command_id: "4712", status: "done", error: null }),
412
+ joe_command_run: () => json("4712"),
413
+ joe_command_output: () => json({ command_id: "4712", status: "pending" }),
306
414
  });
307
415
  let clock = 1000;
308
416
  const outcome = await runCommand({
309
417
  apiKey: "k",
310
418
  apiBaseUrl: BASE,
311
- command: "plan",
312
- projectId: 12,
313
- sql: "select 1",
419
+ instanceId: 3,
420
+ command: "plan select 1",
314
421
  budgetMs: 5,
315
422
  pollIntervalMs: 0,
316
423
  now: () => (clock += 10),
317
424
  sleep: async () => {},
318
425
  });
319
426
  expect(outcome.budgetExpired).toBe(true);
320
- expect(outcome.status).toBe("running");
321
- expect(outcome.result).toBeNull();
427
+ expect(outcome.status).toBe("pending");
428
+ expect(outcome.output).toBeNull();
322
429
  expect(outcome.commandId).toBe("4712");
323
430
  });
324
431
 
325
- test("terminal error state fetches the result (error body)", async () => {
432
+ test("a stalled output request is aborted and returns a resume handle", async () => {
433
+ let calls = 0;
434
+ globalThis.fetch = mock((_url: string, options: RequestInit) => {
435
+ calls += 1;
436
+ if (calls === 1) {
437
+ return Promise.resolve(json("4713"));
438
+ }
439
+ return new Promise<Response>((_resolve, reject) => {
440
+ options.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true });
441
+ });
442
+ }) as unknown as typeof fetch;
443
+
444
+ const outcome = await runCommand({
445
+ apiKey: "k",
446
+ apiBaseUrl: BASE,
447
+ instanceId: 3,
448
+ command: "plan select 1",
449
+ budgetMs: 10,
450
+ pollIntervalMs: 0,
451
+ sleep: async () => {},
452
+ });
453
+ expect(outcome).toMatchObject({
454
+ commandId: "4713",
455
+ status: "pending",
456
+ output: null,
457
+ budgetExpired: true,
458
+ });
459
+ });
460
+
461
+ test("a terminal error output is returned in full", async () => {
326
462
  installFetch({
327
- joe_command_submit: () => json({ command_id: "5", session_id: null, status: "queued" }),
328
- joe_command_status: () => json({ command_id: "5", status: "error", error: "PT500" }),
329
- joe_command_result: () => json({ command_id: "5", status: "error", error: "boom", plan_json: {} }),
463
+ joe_command_run: () => json("5"),
464
+ joe_command_output: () => json({ command_id: "5", status: "error", error: "ERROR: relation \"nope\" does not exist" }),
330
465
  });
331
466
  const outcome = await runCommand({
332
467
  apiKey: "k",
333
468
  apiBaseUrl: BASE,
334
- command: "plan",
335
- projectId: 12,
336
- sql: "select 1",
469
+ instanceId: 3,
470
+ command: "explain select * from nope",
337
471
  pollIntervalMs: 0,
338
472
  sleep: async () => {},
339
473
  });
340
474
  expect(outcome.status).toBe("error");
341
- expect(outcome.result?.error).toBe("boom");
475
+ expect(outcome.budgetExpired).toBe(false);
476
+ expect(outcome.output?.error).toContain("does not exist");
342
477
  });
343
478
 
344
- test("a NaN budget is clamped to the default budget (never an unbounded poll)", async () => {
345
- // NaN survives `?? DEFAULT_BUDGET_MS` (it is neither null nor undefined), and
346
- // `now() >= NaN` is always falsewithout a clamp the poll loop never exits.
347
- // The status route trips a breaker well past the default-budget poll count so
348
- // a regression fails fast instead of hanging the test runner.
349
- let statusCalls = 0;
479
+ test("a transient 502 mid-poll never discards the id: keeps polling and recovers", async () => {
480
+ // startCommand already returned a valid command id; a proxy hiccup on ONE
481
+ // output poll must not throw it away the loop retries within the budget.
482
+ let outputCalls = 0;
350
483
  installFetch({
351
- joe_command_submit: () => json({ command_id: "9", session_id: null, status: "queued" }),
352
- joe_command_status: () => {
353
- statusCalls += 1;
354
- if (statusCalls > 60) {
355
- throw new Error("unbounded poll loop: NaN budget never expired");
484
+ joe_command_run: () => json("4714"),
485
+ joe_command_output: () => {
486
+ outputCalls += 1;
487
+ if (outputCalls === 1) {
488
+ return new Response("Bad Gateway", { status: 502 });
356
489
  }
357
- return json({ command_id: "9", status: "running", error: null });
490
+ return json({ command_id: "4714", status: "ok", plan_text: "Index Scan", error: null });
358
491
  },
359
492
  });
360
- let clock = 0;
361
493
  const outcome = await runCommand({
362
494
  apiKey: "k",
363
495
  apiBaseUrl: BASE,
364
- command: "plan",
365
- projectId: 12,
366
- sql: "select 1",
367
- budgetMs: Number.NaN,
496
+ instanceId: 3,
497
+ command: "plan select 1",
368
498
  pollIntervalMs: 0,
369
- now: () => (clock += 1000), // 1 s per observation → passes the 25 s default budget in <30 polls
370
499
  sleep: async () => {},
371
500
  });
372
- expect(outcome.budgetExpired).toBe(true);
373
- expect(statusCalls).toBeLessThanOrEqual(60);
501
+ expect(outcome.status).toBe("ok");
502
+ expect(outcome.commandId).toBe("4714");
503
+ expect(outcome.output?.plan_text).toBe("Index Scan");
504
+ expect(outputCalls).toBe(2);
374
505
  });
375
506
 
376
- test("exec submits a STABLE idempotency key", async () => {
377
- const captured = installFetch({
378
- joe_command_submit: () => json({ command_id: "6", session_id: "88", status: "queued" }),
379
- joe_command_status: () => json({ command_id: "6", status: "done", error: null }),
380
- joe_command_result: () => json({ command_id: "6", status: "done", row_count: 0, notices: ["CREATE INDEX"], error: null }),
507
+ test("a persistent 502 surfaces the id as a resume handle instead of throwing", async () => {
508
+ installFetch({
509
+ joe_command_run: () => json("4715"),
510
+ joe_command_output: () => new Response("Bad Gateway", { status: 502 }),
381
511
  });
382
- await runCommand({
512
+ let clock = 0;
513
+ const outcome = await runCommand({
383
514
  apiKey: "k",
384
515
  apiBaseUrl: BASE,
385
- command: "exec",
386
- projectId: 12,
387
- sql: "create index on users (email)",
516
+ instanceId: 3,
517
+ command: "plan select 1",
518
+ budgetMs: 25,
388
519
  pollIntervalMs: 0,
520
+ now: () => (clock += 10),
389
521
  sleep: async () => {},
390
522
  });
391
- const expected = computeIdempotencyKey("exec", 12, "create index on users (email)", null);
392
- expect(captured[0].body.idempotency_key).toBe(expected);
523
+ expect(outcome).toMatchObject({
524
+ commandId: "4715",
525
+ status: "pending",
526
+ output: null,
527
+ budgetExpired: true,
528
+ });
393
529
  });
394
- });
395
530
 
396
- describe("executeJoeCommand session threading + persistence", () => {
397
- test("auto-reuses the stored session id for the project", async () => {
398
- const dir = tempDir();
399
- writeStoredSessionId(12, "88", dir);
400
- const captured = installFetch({
401
- joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
402
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
403
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
531
+ test("a persistent 429 (rate limit) also surfaces the id as a resume handle", async () => {
532
+ installFetch({
533
+ joe_command_run: () => json("4716"),
534
+ joe_command_output: () => json({ message: "rate limited" }, 429),
404
535
  });
405
- await executeJoeCommand({
536
+ let clock = 0;
537
+ const outcome = await runCommand({
406
538
  apiKey: "k",
407
539
  apiBaseUrl: BASE,
408
- command: "plan",
409
- project: "12",
410
- sql: "select 1",
411
- sessionDir: dir,
540
+ instanceId: 3,
541
+ command: "plan select 1",
542
+ budgetMs: 25,
412
543
  pollIntervalMs: 0,
544
+ now: () => (clock += 10),
413
545
  sleep: async () => {},
414
546
  });
415
- expect(captured[0].body.session_id).toBe("88");
547
+ expect(outcome).toMatchObject({
548
+ commandId: "4716",
549
+ status: "pending",
550
+ output: null,
551
+ budgetExpired: true,
552
+ });
416
553
  });
417
554
 
418
- test("persists the session id returned by submit", async () => {
419
- const dir = tempDir();
555
+ test("a terminal 404 mid-poll DOES abort (polling on cannot succeed)", async () => {
420
556
  installFetch({
421
- joe_command_submit: () => json({ command_id: "1", session_id: "99", status: "queued" }),
422
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
423
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
557
+ joe_command_run: () => json("4717"),
558
+ joe_command_output: () => json({ code: "PT404", message: "Not found", details: "Specified command not found." }, 404),
424
559
  });
425
- await executeJoeCommand({
560
+ await expect(
561
+ runCommand({
562
+ apiKey: "k",
563
+ apiBaseUrl: BASE,
564
+ instanceId: 3,
565
+ command: "plan select 1",
566
+ pollIntervalMs: 0,
567
+ sleep: async () => {},
568
+ })
569
+ ).rejects.toThrow(/Failed to fetch command output: HTTP 404/);
570
+ });
571
+
572
+ test("a NaN budget is clamped to the default budget (never an unbounded poll)", async () => {
573
+ // NaN survives `?? DEFAULT_BUDGET_MS` (it is neither null nor undefined), and
574
+ // `now() >= NaN` is always false — without a clamp the poll loop never exits.
575
+ // The output route trips a breaker well past the default-budget poll count so
576
+ // a regression fails fast instead of hanging the test runner.
577
+ let outputCalls = 0;
578
+ installFetch({
579
+ joe_command_run: () => json("9"),
580
+ joe_command_output: () => {
581
+ outputCalls += 1;
582
+ if (outputCalls > 60) {
583
+ throw new Error("unbounded poll loop: NaN budget never expired");
584
+ }
585
+ return json({ command_id: "9", status: "pending" });
586
+ },
587
+ });
588
+ let clock = 0;
589
+ const outcome = await runCommand({
426
590
  apiKey: "k",
427
591
  apiBaseUrl: BASE,
428
- command: "plan",
429
- project: "12",
430
- sql: "select 1",
431
- sessionDir: dir,
592
+ instanceId: 3,
593
+ command: "plan select 1",
594
+ budgetMs: Number.NaN,
432
595
  pollIntervalMs: 0,
596
+ now: () => (clock += 1000), // 1 s per observation → passes the 25 s default budget in <30 polls
433
597
  sleep: async () => {},
434
598
  });
435
- expect(readStoredSessionId(12, dir)).toBe("99");
599
+ expect(outcome.budgetExpired).toBe(true);
600
+ expect(outputCalls).toBeLessThanOrEqual(60);
436
601
  });
602
+ });
437
603
 
438
- test("--new-session clears the stored session and submits null", async () => {
439
- const dir = tempDir();
440
- writeStoredSessionId(12, "88", dir);
604
+ describe("executeJoeCommand target build run", () => {
605
+ test("resolves an alias to the project's instance_id and sends the raw text", async () => {
441
606
  const captured = installFetch({
442
- joe_command_submit: () => json({ command_id: "1", session_id: "100", status: "queued" }),
443
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
444
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
607
+ projects_list: () => json([{ project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, instance_id: 3 }]),
608
+ joe_command_run: () => json("77"),
609
+ joe_command_output: () => json({ command_id: "77", status: "ok", plan_text: "ok", error: null }),
445
610
  });
446
- await executeJoeCommand({
611
+ const outcome = await executeJoeCommand({
447
612
  apiKey: "k",
448
613
  apiBaseUrl: BASE,
449
614
  command: "plan",
450
- project: "12",
451
- sql: "select 1",
452
- newSession: true,
453
- sessionDir: dir,
615
+ project: "main-db",
616
+ input: { arg: "select 1" },
454
617
  pollIntervalMs: 0,
455
618
  sleep: async () => {},
456
619
  });
457
- expect(captured[0].body.session_id).toBeNull();
458
- // The freshly returned session id is persisted for the next command.
459
- expect(readStoredSessionId(12, dir)).toBe("100");
620
+ expect(outcome.instanceId).toBe(3);
621
+ expect(outcome.commandText).toBe("plan select 1");
622
+ expect(outcome.status).toBe("ok");
623
+ const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
624
+ expect(run?.body).toEqual({ instance_id: 3, command: "plan select 1" });
460
625
  });
461
626
 
462
- test("explicit --session overrides the stored session", async () => {
463
- const dir = tempDir();
464
- writeStoredSessionId(12, "88", dir);
627
+ test("a bad verb argument fails BEFORE any network call", async () => {
465
628
  const captured = installFetch({
466
- joe_command_submit: () => json({ command_id: "1", session_id: "77", status: "queued" }),
467
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
468
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
629
+ projects_list: () => json([]),
630
+ joe_command_run: () => json("1"),
469
631
  });
470
- await executeJoeCommand({
632
+ await expect(
633
+ executeJoeCommand({
634
+ apiKey: "k",
635
+ apiBaseUrl: BASE,
636
+ command: "terminate",
637
+ project: "12",
638
+ input: { arg: "12x" },
639
+ })
640
+ ).rejects.toThrow("pid must be a positive integer");
641
+ expect(captured.length).toBe(0);
642
+ });
643
+
644
+ test("a direct instanceId skips project resolution entirely", async () => {
645
+ // The v1 path: projects_list is not deployed, so the instance id is given
646
+ // directly and must go straight to joe_command_run — as the exact string,
647
+ // never resolved, never Number()ed.
648
+ const captured = installFetch({
649
+ joe_command_run: () => json("80"),
650
+ joe_command_output: () => json({ command_id: "80", status: "ok", response: "ok", error: null }),
651
+ });
652
+ const outcome = await executeJoeCommand({
471
653
  apiKey: "k",
472
654
  apiBaseUrl: BASE,
473
- command: "plan",
474
- project: "12",
475
- sql: "select 1",
476
- session: "77",
477
- sessionDir: dir,
655
+ command: "explain",
656
+ instanceId: "9007199254740994001",
657
+ input: { arg: "select 1" },
478
658
  pollIntervalMs: 0,
479
659
  sleep: async () => {},
480
660
  });
481
- expect(captured[0].body.session_id).toBe("77");
661
+ expect(outcome.status).toBe("ok");
662
+ expect(outcome.instanceId).toBe("9007199254740994001");
663
+ expect(captured.some((c) => c.url.endsWith("/rpc/projects_list"))).toBe(false);
664
+ const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
665
+ expect(run?.body).toEqual({ instance_id: "9007199254740994001", command: "explain select 1" });
482
666
  });
483
667
 
484
- test("resolves an alias to its project id before submitting", async () => {
485
- const dir = tempDir();
668
+ test("instanceId wins over project when both are given (no projects_list call)", async () => {
486
669
  const captured = installFetch({
487
- projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true }]),
488
- joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
489
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
490
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
670
+ projects_list: () => json([{ project_id: 12, alias: "main-db", joe_ready: true, instance_id: 3 }]),
671
+ joe_command_run: () => json("81"),
672
+ joe_command_output: () => json({ command_id: "81", status: "ok", response: "ok", error: null }),
491
673
  });
492
- const outcome = await executeJoeCommand({
674
+ await executeJoeCommand({
493
675
  apiKey: "k",
494
676
  apiBaseUrl: BASE,
495
- command: "plan",
677
+ command: "reset",
496
678
  project: "main-db",
497
- sql: "select 1",
498
- sessionDir: dir,
679
+ instanceId: "7",
499
680
  pollIntervalMs: 0,
500
681
  sleep: async () => {},
501
682
  });
502
- expect(outcome.projectId).toBe(12);
503
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
504
- expect(submit?.body.project_id).toBe(12);
683
+ expect(captured.some((c) => c.url.endsWith("/rpc/projects_list"))).toBe(false);
684
+ const run = captured.find((c) => c.url.endsWith("/rpc/joe_command_run"));
685
+ expect(run?.body.instance_id).toBe("7");
505
686
  });
506
- });
507
687
 
508
- describe("session store helpers", () => {
509
- test("write / read / clear round-trip", () => {
510
- const dir = tempDir();
511
- expect(readStoredSessionId(42, dir)).toBeNull();
512
- writeStoredSessionId(42, "500", dir);
513
- expect(readStoredSessionId(42, dir)).toBe("500");
514
- expect(existsSync(resolve(dir, "joe-sessions.json"))).toBe(true);
515
- clearStoredSessionId(42, dir);
516
- expect(readStoredSessionId(42, dir)).toBeNull();
517
- });
518
-
519
- test("does not clobber other projects' sessions", () => {
520
- const dir = tempDir();
521
- writeStoredSessionId(1, "a", dir);
522
- writeStoredSessionId(2, "b", dir);
523
- clearStoredSessionId(1, dir);
524
- expect(readStoredSessionId(1, dir)).toBeNull();
525
- expect(readStoredSessionId(2, dir)).toBe("b");
526
- const raw = JSON.parse(readFileSync(resolve(dir, "joe-sessions.json"), "utf8"));
527
- expect(raw).toEqual({ "2": "b" });
688
+ test("a malformed instanceId is rejected before any network call", async () => {
689
+ const captured = installFetch({ joe_command_run: () => json("1") });
690
+ await expect(
691
+ executeJoeCommand({ apiKey: "k", apiBaseUrl: BASE, command: "reset", instanceId: "1; drop" })
692
+ ).rejects.toThrow("instanceId must be a numeric Joe instance id");
693
+ expect(captured.length).toBe(0);
694
+ });
695
+
696
+ test("neither instanceId nor project is a clear error before any network call", async () => {
697
+ const captured = installFetch({ joe_command_run: () => json("1") });
698
+ await expect(
699
+ executeJoeCommand({ apiKey: "k", apiBaseUrl: BASE, command: "reset" })
700
+ ).rejects.toThrow("either instanceId or project is required");
701
+ expect(captured.length).toBe(0);
702
+ });
703
+
704
+ test("the full output (plan_execution_*, stats, recommendations) reaches the outcome", async () => {
705
+ installFetch({
706
+ projects_list: () => json([{ project_id: 12, alias: "main-db", joe_ready: true, instance_id: 3 }]),
707
+ joe_command_run: () => json("78"),
708
+ joe_command_output: () =>
709
+ json({
710
+ command_id: "78",
711
+ status: "ok",
712
+ command: "explain",
713
+ query: "select 1",
714
+ queryid: "991",
715
+ plan_text: "Result",
716
+ plan_json: [{ Plan: { "Node Type": "Result" } }],
717
+ plan_execution_text: "Result (actual time=0.001..0.002)",
718
+ stats: "Time: 0.5 ms",
719
+ recommendations: "looks good",
720
+ error: null,
721
+ }),
722
+ });
723
+ const outcome = await executeJoeCommand({
724
+ apiKey: "k",
725
+ apiBaseUrl: BASE,
726
+ command: "explain",
727
+ project: "12",
728
+ input: { arg: "select 1" },
729
+ pollIntervalMs: 0,
730
+ sleep: async () => {},
731
+ });
732
+ expect(outcome.output).toMatchObject({
733
+ plan_execution_text: "Result (actual time=0.001..0.002)",
734
+ stats: "Time: 0.5 ms",
735
+ recommendations: "looks good",
736
+ queryid: "991",
737
+ });
528
738
  });
529
739
  });
530
740
 
@@ -540,319 +750,106 @@ describe("presentation helpers", () => {
540
750
  expect(flags[0]).toContain("Seq Scan on users");
541
751
  });
542
752
 
543
- test("formatProjectsTable renders the brief's column header", () => {
544
- const table = formatProjectsTable([
545
- { project_id: 12, alias: "main-db", name: "Main DB", label: null, joe_ready: true, tunnel: true, instance_id: null, dblab_instance_id: 7 },
753
+ test("clientSidePlanFlags handles the EXPLAIN json array form the rpc returns", () => {
754
+ // plan_json is unwrapped server-side into EXPLAIN's native array shape:
755
+ // [{ "Plan": { } }].
756
+ const flags = clientSidePlanFlags([
757
+ { Plan: { "Node Type": "Seq Scan", "Relation Name": "orders" } },
546
758
  ]);
547
- const [header, row] = table.split("\n");
548
- expect(header).toContain("PROJECT_ID");
549
- expect(header).toContain("ALIAS");
550
- expect(header).toContain("JOE");
551
- expect(header).toContain("TUNNEL");
552
- expect(row).toContain("12");
553
- expect(row).toContain("main-db");
554
- expect(row).toContain("ready");
555
- expect(row).toContain("yes");
759
+ expect(flags.length).toBe(1);
760
+ expect(flags[0]).toContain("Seq Scan on orders");
761
+ });
762
+
763
+ test("clientSidePlanFlags handles empty, root, and multiple nested plans", () => {
764
+ expect(clientSidePlanFlags(null)).toEqual([]);
765
+ expect(clientSidePlanFlags({})).toEqual([]);
766
+ expect(clientSidePlanFlags({ "Node Type": "Seq Scan", "Relation Name": "root" })[0]).toContain("root");
767
+ expect(clientSidePlanFlags({
768
+ Plan: {
769
+ "Node Type": "Append",
770
+ Plans: [
771
+ { "Node Type": "Seq Scan", "Relation Name": "a" },
772
+ { "Node Type": "Seq Scan", "Relation Name": "b" },
773
+ ],
774
+ },
775
+ })).toHaveLength(2);
556
776
  });
557
777
 
558
- test("formatJoeResult renders per-command bodies", () => {
559
- const plan = formatJoeResult("plan", {
778
+ test("formatJoeOutput prints every present section of the uniform row", () => {
779
+ const output: JoeCommandOutput = {
560
780
  command_id: "1",
561
- status: "done",
562
- error: null,
781
+ status: "ok",
563
782
  plan_text: "Seq Scan on users",
564
- plan_json: { Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } },
565
- queryid: "77",
566
- plan_fingerprint: "a1",
567
- });
568
- expect(plan).toContain("Seq Scan on users");
569
- expect(plan).toContain("⚑");
570
-
571
- const exec = formatJoeResult("exec", {
572
- command_id: "2",
573
- status: "done",
783
+ plan_json: [{ Plan: { "Node Type": "Seq Scan", "Relation Name": "users" } }],
784
+ plan_execution_text: "Seq Scan on users (actual time=0.1..12.3)",
785
+ stats: "Time: 12.4 ms",
786
+ recommendations: ":warning: Seq Scan detected",
787
+ queryid: "7712349901234567890",
574
788
  error: null,
575
- row_count: 0,
576
- notices: ["CREATE INDEX"],
577
- });
578
- expect(exec).toContain("CREATE INDEX");
579
-
580
- const terminate = formatJoeResult("terminate", {
581
- command_id: "3",
582
- status: "done",
583
- error: null,
584
- terminated: true,
585
- pid: 4711,
586
- });
587
- expect(terminate).toContain("pid 4711");
588
- });
589
- });
590
-
591
- describe("MCP tools", () => {
592
- const ROOT = { apiKey: "k", apiBaseUrl: BASE };
593
- const originalXdg = process.env.XDG_CONFIG_HOME;
594
-
595
- function makeReq(name: string, args?: Record<string, unknown>): McpToolRequest {
596
- return { params: { name, arguments: args } };
597
- }
598
-
599
- afterEach(() => {
600
- globalThis.fetch = originalFetch;
601
- if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
602
- else process.env.XDG_CONFIG_HOME = originalXdg;
603
- });
604
-
605
- test("joeToolDefinitions exposes every SPEC §5 name + list_projects", () => {
606
- const names = joeToolDefinitions().map((t) => t.name);
607
- expect(names).toEqual([
608
- "plan_query",
609
- "explain_query",
610
- "exec_sql",
611
- "hypo_index",
612
- "activity",
613
- "terminate_backend",
614
- "reset_clone",
615
- "describe",
616
- "list_projects",
617
- ]);
618
- // Tool-name -> command map covers the 8 command tools.
619
- expect(Object.keys(JOE_TOOL_TO_COMMAND).length).toBe(8);
620
- });
621
-
622
- test("exec_sql carries the stronger raw-data warning; plan/explain note the plan egress", () => {
623
- const defs = joeToolDefinitions();
624
- const exec = defs.find((t) => t.name === "exec_sql");
625
- expect(exec?.description).toMatch(/REAL .*TABLE ROWS/i);
626
- expect(exec?.description).toContain("LLM context");
627
- const plan = defs.find((t) => t.name === "plan_query");
628
- expect(plan?.description).toMatch(/plan-only/i);
629
- expect(plan?.description).toMatch(/NEVER executes/i);
630
- const explain = defs.find((t) => t.name === "explain_query");
631
- expect(explain?.description).toMatch(/EXECUTES/);
632
- expect(explain?.description).toMatch(/NOT raw SELECT result rows/i);
633
- });
634
-
635
- test("plan_query calls executeJoeCommand and returns the plan result", () => {
636
- process.env.XDG_CONFIG_HOME = tempDir();
637
- const captured = installFetch({
638
- joe_command_submit: () => json({ command_id: "4711", session_id: "88", status: "queued" }),
639
- joe_command_status: () => json({ command_id: "4711", status: "done", error: null }),
640
- joe_command_result: () =>
641
- json({ command_id: "4711", status: "done", plan_text: "Seq Scan on users", plan_json: {}, error: null }),
642
- });
643
- return handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "12" }), ROOT).then((res) => {
644
- expect(res.isError).toBeFalsy();
645
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
646
- expect(submit?.body.command).toBe("plan");
647
- expect(submit?.body.project_id).toBe(12);
648
- const payload = JSON.parse(res.content[0].text) as { status: string; result: { plan_text: string } };
649
- expect(payload.status).toBe("done");
650
- expect(payload.result.plan_text).toBe("Seq Scan on users");
651
- });
652
- });
653
-
654
- test("exec_sql maps sql; requires it", async () => {
655
- process.env.XDG_CONFIG_HOME = tempDir();
656
- const captured = installFetch({
657
- joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
658
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
659
- joe_command_result: () => json({ command_id: "1", command: "exec", status: "done", row_count: 0, notices: ["CREATE INDEX"], error: null }),
660
- });
661
- const ok = await handleToolCall(makeReq("exec_sql", { sql: "create index on users (email)", project_id: "12" }), ROOT);
662
- expect(ok.isError).toBeFalsy();
663
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
664
- expect(submit?.body.command).toBe("exec");
665
-
666
- const missing = await handleToolCall(makeReq("exec_sql", { project_id: "12" }), ROOT);
667
- expect(missing.isError).toBe(true);
668
- expect(missing.content[0].text).toContain("sql is required");
669
- });
670
-
671
- test("hypo_index requires query and maps args.query", async () => {
672
- process.env.XDG_CONFIG_HOME = tempDir();
673
- const captured = installFetch({
674
- joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
675
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
676
- joe_command_result: () => json({ command_id: "1", status: "done", hypo_used: true, hypo_plan: {}, error: null }),
677
- });
678
- const missing = await handleToolCall(makeReq("hypo_index", { sql: "create index on orders (customer_id)", project_id: "12" }), ROOT);
679
- expect(missing.isError).toBe(true);
680
- expect(missing.content[0].text).toContain("query is required");
681
-
682
- const ok = await handleToolCall(
683
- makeReq("hypo_index", { sql: "create index on orders (customer_id)", query: "select 1", project_id: "12" }),
684
- ROOT
685
- );
686
- expect(ok.isError).toBeFalsy();
687
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
688
- expect(submit?.body.command).toBe("hypo");
689
- expect(submit?.body.args).toEqual({ query: "select 1" });
690
- });
691
-
692
- test("terminate_backend maps args.pid; describe maps args.object", async () => {
693
- process.env.XDG_CONFIG_HOME = tempDir();
694
- const captured = installFetch({
695
- joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" }),
696
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
697
- joe_command_result: () => json({ command_id: "1", status: "done", terminated: true, pid: 4711, snapshot: {}, error: null }),
698
- });
699
- await handleToolCall(makeReq("terminate_backend", { pid: 4711, project_id: "12" }), ROOT);
700
- let submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
701
- expect(submit?.body.command).toBe("terminate");
702
- expect(submit?.body.args).toEqual({ pid: 4711 });
703
-
704
- captured.length = 0;
705
- await handleToolCall(makeReq("describe", { object: "users", variant: "\\d+", project_id: "12" }), ROOT);
706
- submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
707
- expect(submit?.body.command).toBe("describe");
708
- expect(submit?.body.args).toEqual({ object: "users", variant: "\\d+" });
709
- });
710
-
711
- test("terminate_backend rejects a non-integer / non-positive pid (no submit)", async () => {
712
- // The CLI requires a bare-digits pid (^[0-9]+$); the MCP path must be as
713
- // strict — `Number.isFinite` alone lets -5 / 1.5 / 0 through to a
714
- // pg_terminate_backend against the wrong (or no) backend.
715
- process.env.XDG_CONFIG_HOME = tempDir();
716
- const captured = installFetch({
717
- joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" }),
718
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
719
- joe_command_result: () => json({ command_id: "1", status: "done", terminated: true, pid: 4711, error: null }),
720
- });
721
- for (const bad of [-5, 1.5, 0, "12x", "1.5", "-5", "abc", Number.NaN]) {
722
- const res = await handleToolCall(makeReq("terminate_backend", { pid: bad, project_id: "12" }), ROOT);
723
- expect(res.isError).toBe(true);
724
- expect(res.content[0].text).toContain("pid");
725
- }
726
- expect(captured.some((c) => c.url.endsWith("/rpc/joe_command_submit"))).toBe(false);
727
-
728
- // A strict positive integer — as a number or a bare-digits string — still submits.
729
- const ok = await handleToolCall(makeReq("terminate_backend", { pid: "4711", project_id: "12" }), ROOT);
730
- expect(ok.isError).toBeFalsy();
731
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
732
- expect(submit?.body.args).toEqual({ pid: 4711 });
733
- });
734
-
735
- test("list_projects tool returns the normalized projects", async () => {
736
- process.env.XDG_CONFIG_HOME = tempDir();
737
- installFetch({
738
- projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true }]),
739
- });
740
- const res = await handleToolCall(makeReq("list_projects", {}), ROOT);
741
- expect(res.isError).toBeFalsy();
742
- const projects = JSON.parse(res.content[0].text) as Array<{ project_id: number; alias: string }>;
743
- expect(projects[0].project_id).toBe(12);
744
- expect(projects[0].alias).toBe("main-db");
745
- });
746
-
747
- test("resolves an alias project_id before submitting", async () => {
748
- process.env.XDG_CONFIG_HOME = tempDir();
749
- const captured = installFetch({
750
- projects_list: () => json([{ id: 12, alias: "main-db", name: "Main DB", joe_ready: true }]),
751
- joe_command_submit: () => json({ command_id: "1", session_id: "88", status: "queued" }),
752
- joe_command_status: () => json({ command_id: "1", status: "done", error: null }),
753
- joe_command_result: () => json({ command_id: "1", status: "done", plan_text: "ok", error: null }),
754
- });
755
- await handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "main-db" }), ROOT);
756
- const submit = captured.find((c) => c.url.endsWith("/rpc/joe_command_submit"));
757
- expect(submit?.body.project_id).toBe(12);
758
- });
759
-
760
- test("ai_enabled=false PT403 surfaces as an MCP error", async () => {
761
- process.env.XDG_CONFIG_HOME = tempDir();
762
- installFetch({
763
- joe_command_submit: () =>
764
- json({ message: "AI features disabled for this org — enable in AI Assistant Settings" }, 403),
765
- });
766
- const res = await handleToolCall(makeReq("plan_query", { sql: "select 1", project_id: "12" }), ROOT);
767
- expect(res.isError).toBe(true);
768
- expect(res.content[0].text).toContain("AI features disabled for this org");
789
+ };
790
+ const text = formatJoeOutput(output);
791
+ expect(text).toContain("plan:");
792
+ expect(text).toContain("Seq Scan on users");
793
+ expect(text).toContain("⚑");
794
+ expect(text).toContain("execution plan (EXPLAIN ANALYZE):");
795
+ expect(text).toContain("stats:");
796
+ expect(text).toContain("Time: 12.4 ms");
797
+ expect(text).toContain("recommendations:");
798
+ expect(text).toContain("(queryid 7712349901234567890)");
769
799
  });
770
800
 
771
- test("missing project_id is an error", async () => {
772
- process.env.XDG_CONFIG_HOME = tempDir();
773
- const res = await handleToolCall(makeReq("plan_query", { sql: "select 1" }), ROOT);
774
- expect(res.isError).toBe(true);
775
- expect(res.content[0].text).toContain("project_id is required");
801
+ test("formatJoeOutput prints a bare response (exec/describe/activity) without labels", () => {
802
+ const text = formatJoeOutput({ command_id: "2", status: "ok", response: "CREATE INDEX", error: null });
803
+ expect(text).toBe("CREATE INDEX");
776
804
  });
777
805
 
778
- test("sanitizeBudgetMs only accepts finite numbers (mirrors the CLI --budget guard)", () => {
779
- // A non-finite budget must fall back to undefined → runCommand's default —
780
- // NaN would survive `?? DEFAULT_BUDGET_MS` and make the poll loop unbounded.
781
- expect(sanitizeBudgetMs(undefined)).toBeUndefined();
782
- expect(sanitizeBudgetMs(null)).toBeUndefined();
783
- expect(sanitizeBudgetMs("abc")).toBeUndefined();
784
- expect(sanitizeBudgetMs(Number.NaN)).toBeUndefined();
785
- expect(sanitizeBudgetMs(Infinity)).toBeUndefined();
786
- expect(sanitizeBudgetMs({})).toBeUndefined();
787
- expect(sanitizeBudgetMs(5000)).toBe(5000);
788
- expect(sanitizeBudgetMs("5000")).toBe(5000);
789
- expect(sanitizeBudgetMs(0)).toBe(0);
806
+ test("formatJoeOutput renders nothing for an empty row", () => {
807
+ expect(formatJoeOutput({ command_id: "3", status: "ok", error: null })).toBe("");
790
808
  });
791
809
 
792
- test("timeout_ms: 0 expires immediately with a resume handle (budget mapped through)", async () => {
793
- process.env.XDG_CONFIG_HOME = tempDir();
794
- installFetch({
795
- joe_command_submit: () => json({ command_id: "31", session_id: "88", status: "queued" }),
796
- joe_command_status: () => json({ command_id: "31", status: "running", error: null }),
797
- });
798
- const res = await handleToolCall(
799
- makeReq("plan_query", { sql: "select 1", project_id: "12", timeout_ms: 0 }),
800
- ROOT
801
- );
802
- // Budget expiry is NOT an error — the caller gets a resume handle.
803
- expect(res.isError).toBeFalsy();
804
- const payload = JSON.parse(res.content[0].text) as {
805
- budget_expired: boolean;
806
- resume?: string;
807
- command_id: string;
808
- };
809
- expect(payload.budget_expired).toBe(true);
810
- expect(payload.resume).toBe("pgai joe result 31");
810
+ test("formatProjectsTable renders the fixed-width columns", () => {
811
+ const table = formatProjectsTable([
812
+ { project_id: 12, alias: "main-db", name: "Main DB", joe_ready: true, tunnel: true, instance_id: 3, dblab_instance_id: 7 },
813
+ ]);
814
+ const [header, row] = table.split("\n");
815
+ expect(header).toContain("PROJECT_ID");
816
+ expect(header).toContain("ALIAS");
817
+ expect(header).toContain("JOE");
818
+ expect(header).toContain("TUNNEL");
819
+ expect(row).toContain("12");
820
+ expect(row).toContain("main-db");
821
+ expect(row).toContain("ready");
822
+ expect(row).toContain("yes");
811
823
  });
812
824
  });
813
825
 
814
826
  describe("--debug credential redaction (joe rpc surface)", () => {
815
827
  test("response bodies have password-named fields redacted before logging", async () => {
816
- // `--debug` (and the MCP caller-controlled debug flag) writes the raw
817
- // response body to stderr; any credential-shaped field must be masked the
818
- // same way the access-token header already is.
828
+ // `--debug` writes the raw response body to stderr; any credential-shaped
829
+ // field must be masked the same way the access-token header already is.
819
830
  const spy = spyOn(console, "error").mockImplementation(() => {});
820
831
  try {
821
832
  installFetch({
822
- joe_command_result: () =>
833
+ joe_command_output: () =>
823
834
  json({
824
835
  command_id: "1",
825
836
  command: "exec",
826
- status: "done",
837
+ status: "ok",
827
838
  error: null,
828
- result_rows: [{ usename: "app", password: "row-secret-xyz" }],
839
+ response: "ok",
840
+ stats: null,
841
+ plan_text: null,
842
+ password: "row-secret-xyz",
829
843
  }),
830
844
  });
831
- await getCommandResult({ apiKey: "k", apiBaseUrl: BASE, commandId: "1", debug: true });
845
+ await getCommandOutput({ apiKey: "k", apiBaseUrl: BASE, commandId: "1", debug: true });
832
846
  const logged = spy.mock.calls.map((c) => c.map(String).join(" ")).join("\n");
833
847
  expect(logged).toContain("Debug: Response body");
834
848
  expect(logged).not.toContain("row-secret-xyz");
835
849
  // The rest of the body still logs (redaction, not suppression).
836
- expect(logged).toContain("usename");
850
+ expect(logged).toContain("command_id");
837
851
  } finally {
838
852
  spy.mockRestore();
839
853
  }
840
854
  });
841
855
  });
842
-
843
- describe("command set", () => {
844
- test("JOE_COMMANDS is the SPEC command set", () => {
845
- const expected: JoeCommand[] = ["plan", "explain", "exec", "hypo", "activity", "terminate", "reset", "describe"];
846
- expect([...JOE_COMMANDS]).toEqual(expected);
847
- });
848
-
849
- test("each command submits with its fixed command name", async () => {
850
- for (const command of JOE_COMMANDS) {
851
- const captured = installFetch({
852
- joe_command_submit: () => json({ command_id: "1", session_id: null, status: "queued" as JoeStatus }),
853
- });
854
- await submitCommand({ apiKey: "k", apiBaseUrl: BASE, command, projectId: 12, idempotencyKey: "x" });
855
- expect(captured[0].body.command).toBe(command);
856
- }
857
- });
858
- });