postgresai 0.16.0-dev.0 → 0.16.0-dev.10

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.
@@ -13,6 +13,32 @@ import { Client } from "pg";
13
13
  import { startMcpServer } from "../lib/mcp-server";
14
14
  import { fetchIssues, fetchIssueComments, createIssueComment, fetchIssue, createIssue, updateIssue, updateIssueComment, fetchActionItem, fetchActionItems, createActionItem, updateActionItem, type ConfigChange } from "../lib/issues";
15
15
  import { fetchReports, fetchAllReports, fetchReportFiles, fetchReportFileData, renderMarkdownForTerminal, parseFlexibleDate } from "../lib/reports";
16
+ import {
17
+ executeJoeCommand,
18
+ listProjects,
19
+ getCommandResult,
20
+ getCommandStatus,
21
+ formatJoeResult,
22
+ formatProjectsTable,
23
+ DEFAULT_BUDGET_MS,
24
+ type JoeCommand,
25
+ type ExecuteJoeOutcome,
26
+ } from "../lib/joe";
27
+ import {
28
+ resolveDblabInstanceId,
29
+ createClone,
30
+ listClones,
31
+ getClone,
32
+ resetClone,
33
+ destroyClone,
34
+ listBranches,
35
+ createBranch,
36
+ deleteBranch,
37
+ branchLog,
38
+ listSnapshots,
39
+ createSnapshot,
40
+ destroySnapshot,
41
+ } from "../lib/dblab";
16
42
  import { resolveBaseUrls } from "../lib/util";
17
43
  import { registerAasCollection, parseVcpus } from "../lib/aas-onboard";
18
44
  import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
@@ -2443,9 +2469,44 @@ interface MonitoringRegistration {
2443
2469
  *
2444
2470
  * Never throws — registration is best-effort; returns null on failure.
2445
2471
  */
2472
+
2473
+ /**
2474
+ * Classify how `mon local-install` should register the monitoring instance,
2475
+ * given the raw `--project` value and the resolved instance id.
2476
+ *
2477
+ * - With an instance id: ADOPT the provisioned instance. No project name is
2478
+ * required (the platform returns the real project); a provided name is
2479
+ * normalized and carried through for messaging/fallback.
2480
+ * - No instance id and no project name: ERROR. The hardcoded
2481
+ * "postgres-ai-monitoring" default was removed, and a nameless legacy
2482
+ * self-registration is rejected by v1.monitoring_instance_register (PT400).
2483
+ * - No instance id but a project name: legacy SELF-REGISTER.
2484
+ *
2485
+ * Pure (no I/O) so the decision is unit-testable independently of the large
2486
+ * install command. `projectName` is the trimmed value, or undefined when empty.
2487
+ */
2488
+ type MonRegistrationPlan =
2489
+ | { kind: "adopt"; projectName: string | undefined }
2490
+ | { kind: "self-register"; projectName: string }
2491
+ | { kind: "error-missing-project"; projectName: undefined };
2492
+
2493
+ function planMonitoringRegistration(args: {
2494
+ project?: string;
2495
+ instanceId?: string;
2496
+ }): MonRegistrationPlan {
2497
+ const projectName = args.project?.trim() || undefined;
2498
+ if (args.instanceId) {
2499
+ return { kind: "adopt", projectName };
2500
+ }
2501
+ if (!projectName) {
2502
+ return { kind: "error-missing-project", projectName: undefined };
2503
+ }
2504
+ return { kind: "self-register", projectName };
2505
+ }
2506
+
2446
2507
  async function registerMonitoringInstance(
2447
2508
  apiKey: string,
2448
- projectName: string,
2509
+ projectName: string | undefined,
2449
2510
  opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
2450
2511
  ): Promise<MonitoringRegistration | null> {
2451
2512
  const { apiBaseUrl } = resolveBaseUrls(opts);
@@ -2457,16 +2518,25 @@ async function registerMonitoringInstance(
2457
2518
  // moment to recover; skipped before the first attempt. Tests pass 0.
2458
2519
  const retryDelayMs = opts?.retryDelayMs ?? 400;
2459
2520
 
2521
+ // Omit project_name entirely when empty: the adopt path (instance_id present)
2522
+ // relies on the platform returning the real project, and a nameless legacy
2523
+ // self-registration is rejected by v1.monitoring_instance_register (PT400).
2524
+ const hasProjectName = !!(projectName && projectName.trim());
2525
+
2460
2526
  if (debug) {
2461
2527
  console.error(`\nDebug: Registering monitoring instance...`);
2462
2528
  console.error(`Debug: POST ${url}`);
2463
- console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
2529
+ console.error(
2530
+ `Debug: ${hasProjectName ? `project_name=${projectName}` : "project_name=(omitted)"}${instanceId ? ` instance_id=${instanceId}` : ""}`
2531
+ );
2464
2532
  }
2465
2533
 
2466
2534
  const requestBody: Record<string, string> = {
2467
2535
  api_token: apiKey,
2468
- project_name: projectName,
2469
2536
  };
2537
+ if (hasProjectName) {
2538
+ requestBody.project_name = projectName as string;
2539
+ }
2470
2540
  if (instanceId) {
2471
2541
  requestBody.instance_id = instanceId;
2472
2542
  }
@@ -3137,8 +3207,11 @@ mon
3137
3207
  // and persisted; the legacy self-registration stays fire-and-forget
3138
3208
  // (issue platform-all#311).
3139
3209
  if (apiKey && !opts.demo) {
3140
- const projectName = opts.project || "postgres-ai-monitoring";
3141
3210
  const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
3211
+ const plan = planMonitoringRegistration({ project: opts.project, instanceId });
3212
+ const projectName = plan.projectName;
3213
+ // `instanceId` truthy ⟺ plan.kind === "adopt"; branch on it directly so
3214
+ // TypeScript narrows instanceId to a defined string in the adopt path.
3142
3215
  if (instanceId) {
3143
3216
  const reg = await registerMonitoringInstance(apiKey, projectName, {
3144
3217
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -3160,11 +3233,17 @@ mon
3160
3233
  // Request succeeded but carried no usable project field — don't claim
3161
3234
  // adoption, but don't report a hard failure either (no re-run needed).
3162
3235
  console.error(
3163
- `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
3236
+ `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project` +
3237
+ (projectName
3238
+ ? ` — reports will use project '${projectName}'`
3239
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3164
3240
  );
3165
3241
  } else {
3166
3242
  console.error(
3167
- `⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3243
+ `⚠ Could not adopt provisioned instance ${instanceId}` +
3244
+ (projectName
3245
+ ? ` — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3246
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3168
3247
  );
3169
3248
  }
3170
3249
 
@@ -3188,6 +3267,17 @@ mon
3188
3267
  `⚠ AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'\n`
3189
3268
  );
3190
3269
  }
3270
+ } else if (plan.kind === "error-missing-project") {
3271
+ // Legacy self-registration (no --instance-id) now requires a project
3272
+ // name: the hardcoded "postgres-ai-monitoring" default was removed, and
3273
+ // v1.monitoring_instance_register raises PT400 for a nameless legacy
3274
+ // registration. Console-provisioned installs should adopt with
3275
+ // --instance-id instead.
3276
+ console.error(
3277
+ "✗ A project name is required for self-registration (the 'postgres-ai-monitoring' default was removed). " +
3278
+ "Re-run with --project <name>, or adopt a console-provisioned instance with --instance-id <uuid>."
3279
+ );
3280
+ process.exitCode = 1;
3191
3281
  } else {
3192
3282
  void registerMonitoringInstance(apiKey, projectName, {
3193
3283
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -5312,6 +5402,695 @@ function tryParseJson(s: string): unknown {
5312
5402
  try { return JSON.parse(s); } catch { return s; }
5313
5403
  }
5314
5404
 
5405
+ // ---------------------------------------------------------------------------
5406
+ // Joe API v2 command surface (SPEC §6). Every verb is a client-side
5407
+ // submit-then-poll one-shot over `v1.joe_command_submit`; `--project` accepts a
5408
+ // numeric id OR an alias/name (id-or-alias resolution). `plan` is plan-only
5409
+ // (EXPLAIN, no execution); `explain` (EXPLAIN ANALYZE) and `exec` execute on the
5410
+ // hardened, ephemeral DBLab clone. The backend rpcs are mocked in tests.
5411
+ // ---------------------------------------------------------------------------
5412
+
5413
+ interface JoeCliOpts {
5414
+ project?: string;
5415
+ session?: string;
5416
+ newSession?: boolean;
5417
+ budget?: number;
5418
+ query?: string;
5419
+ variant?: string;
5420
+ debug?: boolean;
5421
+ json?: boolean;
5422
+ }
5423
+
5424
+ // ============================================================================
5425
+ // DBLab companion command surface (Joe API v2 · SPEC §8)
5426
+ //
5427
+ // `pgai dblab clone|branch|snapshot …` proxy the SAME Platform DBLab API the Console
5428
+ // already drives (v1.dblab_api_call). Every verb is `--project <id|alias>`
5429
+ // scoped: the project's single DBLab instance is resolved server-side listing,
5430
+ // then the verb is proxied. The DESTRUCTIVE verbs — the HTTP DELETEs: clone
5431
+ // destroy, branch delete, snapshot destroy — require the `joe:admin` token
5432
+ // scope (user-approved tightening, !612); clone reset is a POST and is NOT
5433
+ // gated. Read/list/create verbs stay at org-token + project-allowlist level.
5434
+ // All enforced backend-side — a PT403 surfaces here as an HTTP 403.
5435
+ //
5436
+ // NOTE: kept in its own region and its own lib (cli/lib/dblab.ts) so it does not
5437
+ // collide with the parallel Joe-commands work.
5438
+ // ============================================================================
5439
+
5440
+ interface DblabCmdOpts {
5441
+ project?: string;
5442
+ debug?: boolean;
5443
+ json?: boolean;
5444
+ }
5445
+
5446
+ function inferCommandFromResult(r: { command?: JoeCommand; plan_json?: unknown; plan_text?: string | null; row_count?: number | null; result_rows?: unknown[] | null; hypo_plan?: unknown; hypo_used?: boolean | null; terminated?: boolean | null; pid?: number | null; reset?: boolean | null; snapshot?: unknown }): JoeCommand | null {
5447
+ if (r.command) return r.command;
5448
+ if (r.plan_text !== undefined || r.plan_json !== undefined) return "plan";
5449
+ if (r.row_count !== undefined || r.result_rows !== undefined) return "exec";
5450
+ if (r.hypo_used !== undefined) return "hypo";
5451
+ if (r.terminated !== undefined) return "terminate";
5452
+ if (r.reset !== undefined) return "reset";
5453
+ if (r.snapshot !== undefined) return "activity";
5454
+ return null;
5455
+ }
5456
+
5457
+ function printJoeOutcome(command: JoeCommand, outcome: ExecuteJoeOutcome, json: boolean, budgetMs?: number): void {
5458
+ // The expiry hint reports the ACTUAL effective budget (--budget when given,
5459
+ // the default otherwise) — not a hardcoded DEFAULT_BUDGET_MS.
5460
+ const effectiveBudgetMs =
5461
+ typeof budgetMs === "number" && Number.isFinite(budgetMs) ? budgetMs : DEFAULT_BUDGET_MS;
5462
+ const budgetSeconds = Math.round(effectiveBudgetMs / 1000);
5463
+ // One-shot budget reached before a terminal state — hand back a resume handle.
5464
+ // This is expected (a cold clone), NOT a failure: exit 0.
5465
+ if (outcome.budgetExpired) {
5466
+ if (json) {
5467
+ console.log(
5468
+ JSON.stringify(
5469
+ {
5470
+ command_id: outcome.commandId,
5471
+ status: outcome.status,
5472
+ session_id: outcome.sessionId,
5473
+ budget_expired: true,
5474
+ resume: `pgai joe result ${outcome.commandId}`,
5475
+ },
5476
+ null,
5477
+ 2
5478
+ )
5479
+ );
5480
+ } else {
5481
+ console.log(
5482
+ `submitted ${outcome.commandId} · ${outcome.status} · budget ${budgetSeconds}s reached — resume: pgai joe result ${outcome.commandId}`
5483
+ );
5484
+ }
5485
+ return;
5486
+ }
5487
+
5488
+ const result = outcome.result;
5489
+ if (outcome.status === "done" && result) {
5490
+ if (json) {
5491
+ console.log(JSON.stringify(result, null, 2));
5492
+ } else {
5493
+ console.log(`submitted ${outcome.commandId} · done`);
5494
+ const body = formatJoeResult(command, result);
5495
+ if (body) console.log(body);
5496
+ }
5497
+ return;
5498
+ }
5499
+
5500
+ if (outcome.status === "error") {
5501
+ const msg = result?.error ?? "command failed";
5502
+ console.error(`command ${outcome.commandId} error: ${msg}`);
5503
+ process.exitCode = 1;
5504
+ return;
5505
+ }
5506
+
5507
+ // Server-side timed_out (the dispatcher gave up). The genuine reply may still
5508
+ // land; suggest a resume, but signal failure with a non-zero exit.
5509
+ console.error(
5510
+ `command ${outcome.commandId} timed out on the server — resume: pgai joe result ${outcome.commandId}`
5511
+ );
5512
+ process.exitCode = 1;
5513
+ }
5514
+
5515
+ async function runJoeCli(
5516
+ command: JoeCommand,
5517
+ sql: string | null,
5518
+ args: Record<string, unknown> | null,
5519
+ opts: JoeCliOpts
5520
+ ): Promise<void> {
5521
+ try {
5522
+ const rootOpts = program.opts<CliOptions>();
5523
+ const cfg = config.readConfig();
5524
+ const { apiKey } = getConfig(rootOpts);
5525
+ if (!apiKey) {
5526
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5527
+ process.exitCode = 1;
5528
+ return;
5529
+ }
5530
+ const projectRef = (opts.project ?? cfg.defaultProject ?? "").toString().trim();
5531
+ if (!projectRef) {
5532
+ console.error("Project is required. Pass --project <id|alias>.");
5533
+ process.exitCode = 1;
5534
+ return;
5535
+ }
5536
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5537
+ const budgetMs =
5538
+ typeof opts.budget === "number" && !Number.isNaN(opts.budget) ? opts.budget * 1000 : undefined;
5539
+
5540
+ const outcome = await executeJoeCommand({
5541
+ apiKey,
5542
+ apiBaseUrl,
5543
+ command,
5544
+ project: projectRef,
5545
+ sql,
5546
+ args,
5547
+ session: opts.session ?? null,
5548
+ newSession: !!opts.newSession,
5549
+ orgId: cfg.orgId ?? undefined,
5550
+ budgetMs,
5551
+ debug: !!opts.debug,
5552
+ });
5553
+
5554
+ printJoeOutcome(command, outcome, !!opts.json, budgetMs);
5555
+ } catch (err) {
5556
+ const message = err instanceof Error ? err.message : String(err);
5557
+ console.error(message);
5558
+ process.exitCode = 1;
5559
+ }
5560
+ }
5561
+
5562
+ /** Attach the shared Joe options (--project / --session / --new-session / --budget). */
5563
+ function withJoeOptions(cmd: import("commander").Command): import("commander").Command {
5564
+ return cmd
5565
+ .option("--project <id|alias>", "target project by numeric id OR alias/name")
5566
+ .option("--session <id>", "run on a specific session's clone")
5567
+ .option("--new-session", "start a fresh session/clone (null session)")
5568
+ .option("--budget <seconds>", "one-shot poll budget in seconds (default 25)", (v) => parseInt(v, 10))
5569
+ .option("--debug", "enable debug output")
5570
+ .option("--json", "output raw JSON");
5571
+ }
5572
+
5573
+ // ---------------------------------------------------------------------------
5574
+ // Joe command group — every Joe verb lives under `pgai joe <verb>` (grouped CLI,
5575
+ // USER-AUTHORIZED restructure). The MCP tool names stay flat/unchanged; only the
5576
+ // CLI invocation path is grouped. `projects` stays top-level (org-level, SPEC §6).
5577
+ // ---------------------------------------------------------------------------
5578
+ const joe = program
5579
+ .command("joe")
5580
+ .description("Joe API v2 — plan/EXPLAIN/exec queries on ephemeral DBLab clones");
5581
+
5582
+ withJoeOptions(
5583
+ joe
5584
+ .command("plan <sql>")
5585
+ .description("plan a query (EXPLAIN, plan-only — no execution; the fast/safe default)")
5586
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5587
+ await runJoeCli("plan", sql, null, opts);
5588
+ });
5589
+
5590
+ withJoeOptions(
5591
+ joe
5592
+ .command("explain <sql>")
5593
+ .description("EXPLAIN ANALYZE a query (EXECUTES on the hardened, ephemeral clone)")
5594
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5595
+ await runJoeCli("explain", sql, null, opts);
5596
+ });
5597
+
5598
+ withJoeOptions(
5599
+ joe
5600
+ .command("exec <sql>")
5601
+ .description("run arbitrary DDL/DML on the clone (e.g. create index, analyze)")
5602
+ ).action(async (sql: string, opts: JoeCliOpts) => {
5603
+ await runJoeCli("exec", sql, null, opts);
5604
+ });
5605
+
5606
+ withJoeOptions(
5607
+ joe
5608
+ .command("hypo <indexSql>")
5609
+ .description("HypoPG hypothetical index + re-plan (no real index, no data change)")
5610
+ .requiredOption("--query <sql>", "target query the hypothetical index is evaluated against")
5611
+ ).action(async (indexSql: string, opts: JoeCliOpts) => {
5612
+ await runJoeCli("hypo", indexSql, { query: opts.query }, opts);
5613
+ });
5614
+
5615
+ withJoeOptions(
5616
+ joe
5617
+ .command("activity")
5618
+ .description("running-activity snapshot (pg_stat_activity; query text redacted)")
5619
+ ).action(async (opts: JoeCliOpts) => {
5620
+ await runJoeCli("activity", null, null, opts);
5621
+ });
5622
+
5623
+ withJoeOptions(
5624
+ joe
5625
+ .command("terminate <pid>")
5626
+ .description("pg_terminate_backend(pid) on the clone")
5627
+ ).action(async (pid: string, opts: JoeCliOpts) => {
5628
+ // A pid must be a bare non-negative integer. parseInt() silently accepts
5629
+ // trailing garbage ("12x"→12), a sign ("-5"→-5), decimals ("1.5"→1) and hex
5630
+ // ("0x10"→0) — any of which would submit a terminate against the WRONG
5631
+ // backend pid. Require a fully-numeric token so a mistyped pid is a clean,
5632
+ // typed rejection that never reaches the submit rpc.
5633
+ const pidStr = (pid ?? "").trim();
5634
+ if (!/^[0-9]+$/.test(pidStr)) {
5635
+ console.error("pid must be a number");
5636
+ process.exitCode = 1;
5637
+ return;
5638
+ }
5639
+ await runJoeCli("terminate", null, { pid: parseInt(pidStr, 10) }, opts);
5640
+ });
5641
+
5642
+ withJoeOptions(
5643
+ joe
5644
+ .command("reset")
5645
+ .description("reset/recreate the session's thin clone")
5646
+ ).action(async (opts: JoeCliOpts) => {
5647
+ await runJoeCli("reset", null, null, opts);
5648
+ });
5649
+
5650
+ withJoeOptions(
5651
+ joe
5652
+ .command("describe <object>")
5653
+ .description("\\d-family schema/relation/index metadata")
5654
+ .option("--variant <variant>", "\\d-family variant (e.g. \\d+, \\di, \\dt)")
5655
+ ).action(async (object: string, opts: JoeCliOpts) => {
5656
+ const args: Record<string, unknown> = { object };
5657
+ if (opts.variant) args.variant = opts.variant;
5658
+ await runJoeCli("describe", null, args, opts);
5659
+ });
5660
+
5661
+ // history search is roadmapped for M1b; the search backend is not deployed yet.
5662
+ // Ship a recognized subcommand so the documented `pgai joe history …` example
5663
+ // degrades cleanly (an informative "not yet available" line + exit 1) instead of
5664
+ // Commander's generic "unknown command 'history'". Wire it to the rpc when M1b lands.
5665
+ withJoeOptions(
5666
+ joe
5667
+ .command("history <terms>")
5668
+ .description("search prior Joe analyses, metadata-only (M1b — not yet available)")
5669
+ ).action(async (_terms: string, _opts: JoeCliOpts) => {
5670
+ console.error(
5671
+ "Joe history search is not available yet — it is planned for M1b. " +
5672
+ "No history-search backend is deployed; this command is a placeholder until then.",
5673
+ );
5674
+ process.exitCode = 1;
5675
+ });
5676
+
5677
+ // Org-level discovery — a general postgresai command, NOT a Joe endpoint (SPEC §6).
5678
+ program
5679
+ .command("projects")
5680
+ .description("list the org's projects (shows which have Joe ready) — org-level, not a Joe endpoint")
5681
+ .option("--debug", "enable debug output")
5682
+ .option("--json", "output raw JSON")
5683
+ .action(async (opts: { debug?: boolean; json?: boolean }) => {
5684
+ try {
5685
+ const rootOpts = program.opts<CliOptions>();
5686
+ const cfg = config.readConfig();
5687
+ const { apiKey } = getConfig(rootOpts);
5688
+ if (!apiKey) {
5689
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5690
+ process.exitCode = 1;
5691
+ return;
5692
+ }
5693
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5694
+ const projects = await listProjects({
5695
+ apiKey,
5696
+ apiBaseUrl,
5697
+ orgId: cfg.orgId ?? undefined,
5698
+ debug: !!opts.debug,
5699
+ });
5700
+ if (opts.json) {
5701
+ console.log(JSON.stringify(projects, null, 2));
5702
+ } else {
5703
+ console.log(formatProjectsTable(projects));
5704
+ }
5705
+ } catch (err) {
5706
+ const message = err instanceof Error ? err.message : String(err);
5707
+ console.error(message);
5708
+ process.exitCode = 1;
5709
+ }
5710
+ });
5711
+
5712
+ /**
5713
+ * Resolve the shared inputs every DBLab verb needs: the api key, the api base
5714
+ * url, and the project's single DBLab `instance_id`. Throws (caught by each
5715
+ * action → exit 1) when the api key or `--project` is missing, or when no DBLab
5716
+ * instance can be resolved for the project.
5717
+ */
5718
+ async function resolveDblabTarget(
5719
+ project: string | undefined,
5720
+ debug: boolean
5721
+ ): Promise<{ apiKey: string; apiBaseUrl: string; orgId?: number; instanceId: string }> {
5722
+ const rootOpts = program.opts<CliOptions>();
5723
+ const cfg = config.readConfig();
5724
+ const { apiKey } = getConfig(rootOpts);
5725
+ if (!apiKey) {
5726
+ throw new Error("API key is required. Run 'pgai auth' first or set --api-key.");
5727
+ }
5728
+ const ref = (project ?? "").trim();
5729
+ if (!ref) {
5730
+ throw new Error("--project <id|alias> is required");
5731
+ }
5732
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5733
+ const orgId = cfg.orgId ?? undefined;
5734
+ const instanceId = await resolveDblabInstanceId({ apiKey, apiBaseUrl, project: ref, orgId, debug });
5735
+ return { apiKey, apiBaseUrl, orgId, instanceId };
5736
+ }
5737
+
5738
+ // ---------------------------------------------------------------------------
5739
+ // DBLab command group — every DBLab verb lives under
5740
+ // `pgai dblab <clone|branch|snapshot> …` (grouped CLI, USER-AUTHORIZED restructure).
5741
+ // MCP tool names (clone_create/…) stay flat/unchanged; only the CLI path is grouped.
5742
+ // ---------------------------------------------------------------------------
5743
+ const dblab = program
5744
+ .command("dblab")
5745
+ .description("DBLab thin-clone / branch / snapshot management (proxies the Platform DBLab API)");
5746
+
5747
+ // ---- clone ----------------------------------------------------------------
5748
+
5749
+ const clone = dblab.command("clone").description("DBLab thin-clone management (proxies the Platform DBLab API)");
5750
+
5751
+ clone
5752
+ .command("create")
5753
+ .description("create a thin clone of the project's database")
5754
+ .requiredOption("--project <id|alias>", "project id or alias")
5755
+ .option("--branch <branch>", "branch to clone from")
5756
+ .option("--snapshot <id>", "snapshot id to clone from")
5757
+ .option("--id <id>", "clone id (DBLab generates one when omitted)")
5758
+ .option("--db-user <user>", "clone DB user")
5759
+ .option("--db-password <password>", "clone DB password")
5760
+ .option("--protected", "protect the clone from auto-deletion")
5761
+ .option("--debug", "enable debug output")
5762
+ .option("--json", "output raw JSON")
5763
+ .action(async (opts: DblabCmdOpts & { branch?: string; snapshot?: string; id?: string; dbUser?: string; dbPassword?: string; protected?: boolean }) => {
5764
+ try {
5765
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5766
+ const result = await createClone({
5767
+ apiKey, apiBaseUrl, instanceId,
5768
+ cloneId: opts.id,
5769
+ branch: opts.branch,
5770
+ snapshotId: opts.snapshot,
5771
+ dbUser: opts.dbUser,
5772
+ dbPassword: opts.dbPassword,
5773
+ isProtected: !!opts.protected,
5774
+ debug: !!opts.debug,
5775
+ });
5776
+ printResult(result, opts.json);
5777
+ } catch (err) {
5778
+ console.error(err instanceof Error ? err.message : String(err));
5779
+ process.exitCode = 1;
5780
+ }
5781
+ });
5782
+
5783
+ // Resume / inspect a previously submitted command by id. Part of the `joe` group
5784
+ // (`pgai joe status <commandId>`) — distinct from `pgai dblab clone status <cloneId>`.
5785
+ joe
5786
+ .command("status <commandId>")
5787
+ .description("show a Joe command's status (metadata only)")
5788
+ .option("--debug", "enable debug output")
5789
+ .option("--json", "output raw JSON")
5790
+ .action(async (commandId: string, opts: { debug?: boolean; json?: boolean }) => {
5791
+ try {
5792
+ const rootOpts = program.opts<CliOptions>();
5793
+ const cfg = config.readConfig();
5794
+ const { apiKey } = getConfig(rootOpts);
5795
+ if (!apiKey) {
5796
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5797
+ process.exitCode = 1;
5798
+ return;
5799
+ }
5800
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5801
+ const status = await getCommandStatus({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5802
+ if (opts.json) {
5803
+ console.log(JSON.stringify(status, null, 2));
5804
+ } else {
5805
+ console.log(`command ${status.command_id} · ${status.status}${status.error ? ` · ${status.error}` : ""}`);
5806
+ }
5807
+ } catch (err) {
5808
+ const message = err instanceof Error ? err.message : String(err);
5809
+ console.error(message);
5810
+ process.exitCode = 1;
5811
+ }
5812
+ });
5813
+
5814
+ clone
5815
+ .command("list")
5816
+ .description("list the project's thin clones")
5817
+ .requiredOption("--project <id|alias>", "project id or alias")
5818
+ .option("--debug", "enable debug output")
5819
+ .option("--json", "output raw JSON")
5820
+ .action(async (opts: DblabCmdOpts) => {
5821
+ try {
5822
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5823
+ const result = await listClones({ apiKey, apiBaseUrl, instanceId, debug: !!opts.debug });
5824
+ printResult(result, opts.json);
5825
+ } catch (err) {
5826
+ console.error(err instanceof Error ? err.message : String(err));
5827
+ process.exitCode = 1;
5828
+ }
5829
+ });
5830
+
5831
+ joe
5832
+ .command("result <commandId>")
5833
+ .description("fetch a Joe command's result by id (resume a timed-out one-shot)")
5834
+ .option("--debug", "enable debug output")
5835
+ .option("--json", "output raw JSON")
5836
+ .action(async (commandId: string, opts: { debug?: boolean; json?: boolean }) => {
5837
+ try {
5838
+ const rootOpts = program.opts<CliOptions>();
5839
+ const cfg = config.readConfig();
5840
+ const { apiKey } = getConfig(rootOpts);
5841
+ if (!apiKey) {
5842
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
5843
+ process.exitCode = 1;
5844
+ return;
5845
+ }
5846
+ const { apiBaseUrl } = resolveBaseUrls(rootOpts, cfg);
5847
+ const result = await getCommandResult({ apiKey, apiBaseUrl, commandId, debug: !!opts.debug });
5848
+ if (opts.json) {
5849
+ console.log(JSON.stringify(result, null, 2));
5850
+ // Same exit contract as the human output and the one-shot path
5851
+ // (printJoeOutcome): a terminal failure is a non-result — scripts
5852
+ // consuming --json must not proceed on it.
5853
+ if (result.status === "error" || result.status === "timed_out") {
5854
+ process.exitCode = 1;
5855
+ }
5856
+ return;
5857
+ }
5858
+ const inferred = inferCommandFromResult(result);
5859
+ if (result.status === "done" && inferred) {
5860
+ console.log(`command ${result.command_id} · done`);
5861
+ const body = formatJoeResult(inferred, result);
5862
+ if (body) console.log(body);
5863
+ } else if (result.status === "error") {
5864
+ console.error(`command ${result.command_id} error: ${result.error ?? "command failed"}`);
5865
+ process.exitCode = 1;
5866
+ } else if (result.status === "timed_out") {
5867
+ // Terminal server-side timeout — a non-result. Match the one-shot path
5868
+ // (printJoeOutcome): report and exit non-zero so `pgai joe result $id
5869
+ // && next-step` scripts do not proceed.
5870
+ console.error(`command ${result.command_id} timed out on the server`);
5871
+ process.exitCode = 1;
5872
+ } else {
5873
+ console.log(`command ${result.command_id} · ${result.status}`);
5874
+ }
5875
+ } catch (err) {
5876
+ const message = err instanceof Error ? err.message : String(err);
5877
+ console.error(message);
5878
+ process.exitCode = 1;
5879
+ }
5880
+ });
5881
+
5882
+ clone
5883
+ .command("status <cloneId>")
5884
+ .description("show a clone's status")
5885
+ .requiredOption("--project <id|alias>", "project id or alias")
5886
+ .option("--debug", "enable debug output")
5887
+ .option("--json", "output raw JSON")
5888
+ .action(async (cloneId: string, opts: DblabCmdOpts) => {
5889
+ try {
5890
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5891
+ const result = await getClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug: !!opts.debug });
5892
+ printResult(result, opts.json);
5893
+ } catch (err) {
5894
+ console.error(err instanceof Error ? err.message : String(err));
5895
+ process.exitCode = 1;
5896
+ }
5897
+ });
5898
+
5899
+ clone
5900
+ .command("reset <cloneId>")
5901
+ .description("reset a clone to a pristine snapshot")
5902
+ .requiredOption("--project <id|alias>", "project id or alias")
5903
+ .option("--snapshot <id>", "snapshot id to reset to (default: latest)")
5904
+ .option("--latest", "reset to the latest snapshot")
5905
+ .option("--debug", "enable debug output")
5906
+ .option("--json", "output raw JSON")
5907
+ .action(async (cloneId: string, opts: DblabCmdOpts & { snapshot?: string; latest?: boolean }) => {
5908
+ try {
5909
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5910
+ const result = await resetClone({
5911
+ apiKey, apiBaseUrl, instanceId, cloneId,
5912
+ snapshotId: opts.snapshot,
5913
+ latest: opts.latest,
5914
+ debug: !!opts.debug,
5915
+ });
5916
+ printResult(result ?? { reset: true, cloneId }, opts.json);
5917
+ } catch (err) {
5918
+ console.error(err instanceof Error ? err.message : String(err));
5919
+ process.exitCode = 1;
5920
+ }
5921
+ });
5922
+
5923
+ clone
5924
+ .command("destroy <cloneId>")
5925
+ .description("destroy a clone (requires joe:admin)")
5926
+ .requiredOption("--project <id|alias>", "project id or alias")
5927
+ .option("--debug", "enable debug output")
5928
+ .option("--json", "output raw JSON")
5929
+ .action(async (cloneId: string, opts: DblabCmdOpts) => {
5930
+ try {
5931
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5932
+ const result = await destroyClone({ apiKey, apiBaseUrl, instanceId, cloneId, debug: !!opts.debug });
5933
+ printResult(result ?? { destroyed: true, cloneId }, opts.json);
5934
+ } catch (err) {
5935
+ console.error(err instanceof Error ? err.message : String(err));
5936
+ process.exitCode = 1;
5937
+ }
5938
+ });
5939
+
5940
+ // ---- branch ---------------------------------------------------------------
5941
+
5942
+ const branch = dblab.command("branch").description("DBLab branch management (proxies the Platform DBLab API)");
5943
+
5944
+ branch
5945
+ .command("list")
5946
+ .description("list the project's branches")
5947
+ .requiredOption("--project <id|alias>", "project id or alias")
5948
+ .option("--debug", "enable debug output")
5949
+ .option("--json", "output raw JSON")
5950
+ .action(async (opts: DblabCmdOpts) => {
5951
+ try {
5952
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5953
+ const result = await listBranches({ apiKey, apiBaseUrl, instanceId, debug: !!opts.debug });
5954
+ printResult(result, opts.json);
5955
+ } catch (err) {
5956
+ console.error(err instanceof Error ? err.message : String(err));
5957
+ process.exitCode = 1;
5958
+ }
5959
+ });
5960
+
5961
+ branch
5962
+ .command("create <name>")
5963
+ .description("create a branch")
5964
+ .requiredOption("--project <id|alias>", "project id or alias")
5965
+ .option("--snapshot <id>", "snapshot id to base the branch on")
5966
+ .option("--base-branch <branch>", "parent branch to fork from")
5967
+ .option("--debug", "enable debug output")
5968
+ .option("--json", "output raw JSON")
5969
+ .action(async (name: string, opts: DblabCmdOpts & { snapshot?: string; baseBranch?: string }) => {
5970
+ try {
5971
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5972
+ const result = await createBranch({
5973
+ apiKey, apiBaseUrl, instanceId,
5974
+ branchName: name,
5975
+ baseBranch: opts.baseBranch,
5976
+ snapshotId: opts.snapshot,
5977
+ debug: !!opts.debug,
5978
+ });
5979
+ printResult(result, opts.json);
5980
+ } catch (err) {
5981
+ console.error(err instanceof Error ? err.message : String(err));
5982
+ process.exitCode = 1;
5983
+ }
5984
+ });
5985
+
5986
+ branch
5987
+ .command("delete <name>")
5988
+ .description("delete a branch (requires joe:admin)")
5989
+ .requiredOption("--project <id|alias>", "project id or alias")
5990
+ .option("--debug", "enable debug output")
5991
+ .option("--json", "output raw JSON")
5992
+ .action(async (name: string, opts: DblabCmdOpts) => {
5993
+ try {
5994
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
5995
+ const result = await deleteBranch({ apiKey, apiBaseUrl, instanceId, branchName: name, debug: !!opts.debug });
5996
+ printResult(result ?? { deleted: true, branch: name }, opts.json);
5997
+ } catch (err) {
5998
+ console.error(err instanceof Error ? err.message : String(err));
5999
+ process.exitCode = 1;
6000
+ }
6001
+ });
6002
+
6003
+ branch
6004
+ .command("log <name>")
6005
+ .description("show a branch's snapshot log")
6006
+ .requiredOption("--project <id|alias>", "project id or alias")
6007
+ .option("--debug", "enable debug output")
6008
+ .option("--json", "output raw JSON")
6009
+ .action(async (name: string, opts: DblabCmdOpts) => {
6010
+ try {
6011
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6012
+ const result = await branchLog({ apiKey, apiBaseUrl, instanceId, branchName: name, debug: !!opts.debug });
6013
+ printResult(result, opts.json);
6014
+ } catch (err) {
6015
+ console.error(err instanceof Error ? err.message : String(err));
6016
+ process.exitCode = 1;
6017
+ }
6018
+ });
6019
+
6020
+ // ---- snapshot -------------------------------------------------------------
6021
+
6022
+ const snapshot = dblab.command("snapshot").description("DBLab snapshot management (proxies the Platform DBLab API)");
6023
+
6024
+ snapshot
6025
+ .command("list")
6026
+ .description("list the project's snapshots")
6027
+ .requiredOption("--project <id|alias>", "project id or alias")
6028
+ .option("--branch <branch>", "filter by branch")
6029
+ .option("--dataset <dataset>", "filter by dataset")
6030
+ .option("--debug", "enable debug output")
6031
+ .option("--json", "output raw JSON")
6032
+ .action(async (opts: DblabCmdOpts & { branch?: string; dataset?: string }) => {
6033
+ try {
6034
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6035
+ const result = await listSnapshots({
6036
+ apiKey, apiBaseUrl, instanceId,
6037
+ branchName: opts.branch,
6038
+ dataset: opts.dataset,
6039
+ debug: !!opts.debug,
6040
+ });
6041
+ printResult(result, opts.json);
6042
+ } catch (err) {
6043
+ console.error(err instanceof Error ? err.message : String(err));
6044
+ process.exitCode = 1;
6045
+ }
6046
+ });
6047
+
6048
+ snapshot
6049
+ .command("create")
6050
+ .description("create a snapshot from a clone")
6051
+ .requiredOption("--project <id|alias>", "project id or alias")
6052
+ .requiredOption("--clone <id>", "clone id to snapshot")
6053
+ .option("--message <message>", "snapshot message")
6054
+ .option("--debug", "enable debug output")
6055
+ .option("--json", "output raw JSON")
6056
+ .action(async (opts: DblabCmdOpts & { clone?: string; message?: string }) => {
6057
+ try {
6058
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6059
+ const result = await createSnapshot({
6060
+ apiKey, apiBaseUrl, instanceId,
6061
+ cloneId: opts.clone as string,
6062
+ message: opts.message,
6063
+ debug: !!opts.debug,
6064
+ });
6065
+ printResult(result, opts.json);
6066
+ } catch (err) {
6067
+ console.error(err instanceof Error ? err.message : String(err));
6068
+ process.exitCode = 1;
6069
+ }
6070
+ });
6071
+
6072
+ snapshot
6073
+ .command("destroy <snapshotId>")
6074
+ .description("destroy a snapshot (requires joe:admin)")
6075
+ .requiredOption("--project <id|alias>", "project id or alias")
6076
+ .option("--force", "force-delete even when dependent clones exist")
6077
+ .option("--debug", "enable debug output")
6078
+ .option("--json", "output raw JSON")
6079
+ .action(async (snapshotId: string, opts: DblabCmdOpts & { force?: boolean }) => {
6080
+ try {
6081
+ const { apiKey, apiBaseUrl, instanceId } = await resolveDblabTarget(opts.project, !!opts.debug);
6082
+ const result = await destroySnapshot({
6083
+ apiKey, apiBaseUrl, instanceId, snapshotId,
6084
+ force: !!opts.force,
6085
+ debug: !!opts.debug,
6086
+ });
6087
+ printResult(result ?? { destroyed: true, snapshot: snapshotId }, opts.json);
6088
+ } catch (err) {
6089
+ console.error(err instanceof Error ? err.message : String(err));
6090
+ process.exitCode = 1;
6091
+ }
6092
+ });
6093
+
5315
6094
  // MCP server
5316
6095
  const mcp = program.command("mcp").description("MCP server integration");
5317
6096
 
@@ -5488,3 +6267,7 @@ if (import.meta.main) {
5488
6267
  // same functions used by the `mon` commands).
5489
6268
  export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
5490
6269
  export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
6270
+ export { planMonitoringRegistration, type MonRegistrationPlan };
6271
+ // Joe: the `pgai joe result <id>` result-body -> command inference (unit-tested
6272
+ // table-driven; the shapes are the per-command contract matrix in CONTRACT_DECISIONS).
6273
+ export { inferCommandFromResult };