postgresai 0.16.0-dev.0 → 0.16.0-dev.2

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.
@@ -2443,9 +2443,44 @@ interface MonitoringRegistration {
2443
2443
  *
2444
2444
  * Never throws — registration is best-effort; returns null on failure.
2445
2445
  */
2446
+
2447
+ /**
2448
+ * Classify how `mon local-install` should register the monitoring instance,
2449
+ * given the raw `--project` value and the resolved instance id.
2450
+ *
2451
+ * - With an instance id: ADOPT the provisioned instance. No project name is
2452
+ * required (the platform returns the real project); a provided name is
2453
+ * normalized and carried through for messaging/fallback.
2454
+ * - No instance id and no project name: ERROR. The hardcoded
2455
+ * "postgres-ai-monitoring" default was removed, and a nameless legacy
2456
+ * self-registration is rejected by v1.monitoring_instance_register (PT400).
2457
+ * - No instance id but a project name: legacy SELF-REGISTER.
2458
+ *
2459
+ * Pure (no I/O) so the decision is unit-testable independently of the large
2460
+ * install command. `projectName` is the trimmed value, or undefined when empty.
2461
+ */
2462
+ type MonRegistrationPlan =
2463
+ | { kind: "adopt"; projectName: string | undefined }
2464
+ | { kind: "self-register"; projectName: string }
2465
+ | { kind: "error-missing-project"; projectName: undefined };
2466
+
2467
+ function planMonitoringRegistration(args: {
2468
+ project?: string;
2469
+ instanceId?: string;
2470
+ }): MonRegistrationPlan {
2471
+ const projectName = args.project?.trim() || undefined;
2472
+ if (args.instanceId) {
2473
+ return { kind: "adopt", projectName };
2474
+ }
2475
+ if (!projectName) {
2476
+ return { kind: "error-missing-project", projectName: undefined };
2477
+ }
2478
+ return { kind: "self-register", projectName };
2479
+ }
2480
+
2446
2481
  async function registerMonitoringInstance(
2447
2482
  apiKey: string,
2448
- projectName: string,
2483
+ projectName: string | undefined,
2449
2484
  opts?: { apiBaseUrl?: string; debug?: boolean; instanceId?: string; retries?: number; retryDelayMs?: number }
2450
2485
  ): Promise<MonitoringRegistration | null> {
2451
2486
  const { apiBaseUrl } = resolveBaseUrls(opts);
@@ -2457,16 +2492,25 @@ async function registerMonitoringInstance(
2457
2492
  // moment to recover; skipped before the first attempt. Tests pass 0.
2458
2493
  const retryDelayMs = opts?.retryDelayMs ?? 400;
2459
2494
 
2495
+ // Omit project_name entirely when empty: the adopt path (instance_id present)
2496
+ // relies on the platform returning the real project, and a nameless legacy
2497
+ // self-registration is rejected by v1.monitoring_instance_register (PT400).
2498
+ const hasProjectName = !!(projectName && projectName.trim());
2499
+
2460
2500
  if (debug) {
2461
2501
  console.error(`\nDebug: Registering monitoring instance...`);
2462
2502
  console.error(`Debug: POST ${url}`);
2463
- console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
2503
+ console.error(
2504
+ `Debug: ${hasProjectName ? `project_name=${projectName}` : "project_name=(omitted)"}${instanceId ? ` instance_id=${instanceId}` : ""}`
2505
+ );
2464
2506
  }
2465
2507
 
2466
2508
  const requestBody: Record<string, string> = {
2467
2509
  api_token: apiKey,
2468
- project_name: projectName,
2469
2510
  };
2511
+ if (hasProjectName) {
2512
+ requestBody.project_name = projectName as string;
2513
+ }
2470
2514
  if (instanceId) {
2471
2515
  requestBody.instance_id = instanceId;
2472
2516
  }
@@ -3137,8 +3181,11 @@ mon
3137
3181
  // and persisted; the legacy self-registration stays fire-and-forget
3138
3182
  // (issue platform-all#311).
3139
3183
  if (apiKey && !opts.demo) {
3140
- const projectName = opts.project || "postgres-ai-monitoring";
3141
3184
  const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
3185
+ const plan = planMonitoringRegistration({ project: opts.project, instanceId });
3186
+ const projectName = plan.projectName;
3187
+ // `instanceId` truthy ⟺ plan.kind === "adopt"; branch on it directly so
3188
+ // TypeScript narrows instanceId to a defined string in the adopt path.
3142
3189
  if (instanceId) {
3143
3190
  const reg = await registerMonitoringInstance(apiKey, projectName, {
3144
3191
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -3160,11 +3207,17 @@ mon
3160
3207
  // Request succeeded but carried no usable project field — don't claim
3161
3208
  // adoption, but don't report a hard failure either (no re-run needed).
3162
3209
  console.error(
3163
- `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project — reports will use project '${projectName}'`
3210
+ `⚠ Adopted provisioned instance ${instanceId} but the platform returned no project` +
3211
+ (projectName
3212
+ ? ` — reports will use project '${projectName}'`
3213
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3164
3214
  );
3165
3215
  } else {
3166
3216
  console.error(
3167
- `⚠ Could not adopt provisioned instance ${instanceId} — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3217
+ `⚠ Could not adopt provisioned instance ${instanceId}` +
3218
+ (projectName
3219
+ ? ` — reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`
3220
+ : ` — reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`)
3168
3221
  );
3169
3222
  }
3170
3223
 
@@ -3188,6 +3241,17 @@ mon
3188
3241
  `⚠ AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'\n`
3189
3242
  );
3190
3243
  }
3244
+ } else if (plan.kind === "error-missing-project") {
3245
+ // Legacy self-registration (no --instance-id) now requires a project
3246
+ // name: the hardcoded "postgres-ai-monitoring" default was removed, and
3247
+ // v1.monitoring_instance_register raises PT400 for a nameless legacy
3248
+ // registration. Console-provisioned installs should adopt with
3249
+ // --instance-id instead.
3250
+ console.error(
3251
+ "✗ A project name is required for self-registration (the 'postgres-ai-monitoring' default was removed). " +
3252
+ "Re-run with --project <name>, or adopt a console-provisioned instance with --instance-id <uuid>."
3253
+ );
3254
+ process.exitCode = 1;
3191
3255
  } else {
3192
3256
  void registerMonitoringInstance(apiKey, projectName, {
3193
3257
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -5488,3 +5552,4 @@ if (import.meta.main) {
5488
5552
  // same functions used by the `mon` commands).
5489
5553
  export { refreshBundledComposeIfStale, readDeployedTag, isValidComposeYaml };
5490
5554
  export { registerMonitoringInstance, resolveAdoptedProject, type MonitoringRegistration };
5555
+ export { planMonitoringRegistration, type MonRegistrationPlan };
@@ -13425,7 +13425,7 @@ var {
13425
13425
  // package.json
13426
13426
  var package_default = {
13427
13427
  name: "postgresai",
13428
- version: "0.16.0-dev.0",
13428
+ version: "0.16.0-dev.2",
13429
13429
  description: "postgres_ai CLI",
13430
13430
  license: "Apache-2.0",
13431
13431
  private: false,
@@ -16256,7 +16256,7 @@ var Result = import_lib.default.Result;
16256
16256
  var TypeOverrides = import_lib.default.TypeOverrides;
16257
16257
  var defaults = import_lib.default.defaults;
16258
16258
  // package.json
16259
- var version = "0.16.0-dev.0";
16259
+ var version = "0.16.0-dev.2";
16260
16260
  var package_default2 = {
16261
16261
  name: "postgresai",
16262
16262
  version,
@@ -28498,7 +28498,7 @@ function grafanaBaseUrl() {
28498
28498
  return (process.env.PGAI_GRAFANA_LOCAL_URL || "http://localhost:3000").replace(/\/+$/, "");
28499
28499
  }
28500
28500
  function grafanaAdminUser() {
28501
- return process.env.GF_SECURITY_ADMIN_USER || "admin";
28501
+ return process.env.GF_SECURITY_ADMIN_USER || "monitor";
28502
28502
  }
28503
28503
  function parseVcpus(raw) {
28504
28504
  if (raw === undefined || raw === null || raw === "")
@@ -28575,9 +28575,14 @@ async function resolveDatasourceId(adminPassword, debug = false) {
28575
28575
  return null;
28576
28576
  const list = await res.json().catch(() => []);
28577
28577
  const prom = list.filter((d) => d.type === "prometheus");
28578
- if (prom.length !== 1) {
28578
+ if (prom.length > 1) {
28579
28579
  if (debug)
28580
- console.error(`Debug: AAS: expected 1 prometheus datasource, found ${prom.length}`);
28580
+ console.error(`Debug: AAS: ${prom.length} prometheus datasources (ambiguous); not retrying`);
28581
+ return "ambiguous";
28582
+ }
28583
+ if (prom.length === 0) {
28584
+ if (debug)
28585
+ console.error(`Debug: AAS: no prometheus datasource resolvable yet`);
28581
28586
  return null;
28582
28587
  }
28583
28588
  return typeof prom[0].id === "number" ? prom[0].id : null;
@@ -28593,7 +28598,23 @@ async function registerAasCollection(apiKey, instanceId, opts) {
28593
28598
  const labels = resolveAasLabels(opts.instancesPath);
28594
28599
  if (!labels)
28595
28600
  return { ok: false, reason: "could not determine a single (cluster, node_name) target" };
28596
- const datasourceId = await resolveDatasourceId(opts.grafanaPassword, debug);
28601
+ const maxAttempts = opts.datasourceMaxAttempts ?? 20;
28602
+ const retryDelayMs = opts.datasourceRetryDelayMs ?? 3000;
28603
+ let datasourceId = null;
28604
+ for (let attempt = 1;attempt <= maxAttempts; attempt++) {
28605
+ const resolved = await resolveDatasourceId(opts.grafanaPassword, debug);
28606
+ if (typeof resolved === "number") {
28607
+ datasourceId = resolved;
28608
+ break;
28609
+ }
28610
+ if (resolved === "ambiguous")
28611
+ break;
28612
+ if (attempt < maxAttempts) {
28613
+ if (debug)
28614
+ console.error(`Debug: AAS: datasource not resolvable yet (attempt ${attempt}/${maxAttempts}); waiting for Grafana…`);
28615
+ await new Promise((resolve4) => setTimeout(resolve4, retryDelayMs));
28616
+ }
28617
+ }
28597
28618
  if (datasourceId == null)
28598
28619
  return { ok: false, reason: "could not resolve the Prometheus datasource id" };
28599
28620
  const saToken = await mintAasServiceAccountToken(opts.grafanaPassword, debug);
@@ -35638,6 +35659,16 @@ function checkRunningContainers() {
35638
35659
  return { running: false, containers: [] };
35639
35660
  }
35640
35661
  }
35662
+ function planMonitoringRegistration(args) {
35663
+ const projectName = args.project?.trim() || undefined;
35664
+ if (args.instanceId) {
35665
+ return { kind: "adopt", projectName };
35666
+ }
35667
+ if (!projectName) {
35668
+ return { kind: "error-missing-project", projectName: undefined };
35669
+ }
35670
+ return { kind: "self-register", projectName };
35671
+ }
35641
35672
  async function registerMonitoringInstance(apiKey, projectName, opts) {
35642
35673
  const { apiBaseUrl } = resolveBaseUrls2(opts);
35643
35674
  const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
@@ -35645,16 +35676,19 @@ async function registerMonitoringInstance(apiKey, projectName, opts) {
35645
35676
  const instanceId = opts?.instanceId;
35646
35677
  const retries = opts?.retries ?? (instanceId ? 1 : 0);
35647
35678
  const retryDelayMs = opts?.retryDelayMs ?? 400;
35679
+ const hasProjectName = !!(projectName && projectName.trim());
35648
35680
  if (debug) {
35649
35681
  console.error(`
35650
35682
  Debug: Registering monitoring instance...`);
35651
35683
  console.error(`Debug: POST ${url}`);
35652
- console.error(`Debug: project_name=${projectName}${instanceId ? ` instance_id=${instanceId}` : ""}`);
35684
+ console.error(`Debug: ${hasProjectName ? `project_name=${projectName}` : "project_name=(omitted)"}${instanceId ? ` instance_id=${instanceId}` : ""}`);
35653
35685
  }
35654
35686
  const requestBody = {
35655
- api_token: apiKey,
35656
- project_name: projectName
35687
+ api_token: apiKey
35657
35688
  };
35689
+ if (hasProjectName) {
35690
+ requestBody.project_name = projectName;
35691
+ }
35658
35692
  if (instanceId) {
35659
35693
  requestBody.instance_id = instanceId;
35660
35694
  }
@@ -36197,8 +36231,9 @@ Searched: ${demoCandidates.join(", ")}
36197
36231
  console.log(`\u2713 Services started
36198
36232
  `);
36199
36233
  if (apiKey && !opts.demo) {
36200
- const projectName = opts.project || "postgres-ai-monitoring";
36201
36234
  const instanceId = opts.instanceId || process.env.PGAI_INSTANCE_ID;
36235
+ const plan = planMonitoringRegistration({ project: opts.project, instanceId });
36236
+ const projectName = plan.projectName;
36202
36237
  if (instanceId) {
36203
36238
  const reg = await registerMonitoringInstance(apiKey, projectName, {
36204
36239
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -36214,9 +36249,9 @@ Searched: ${demoCandidates.join(", ")}
36214
36249
  console.log(`\u2713 ${verb} monitoring instance (project: ${adoptedProject})
36215
36250
  `);
36216
36251
  } else if (reg) {
36217
- console.error(`\u26A0 Adopted provisioned instance ${instanceId} but the platform returned no project \u2014 reports will use project '${projectName}'`);
36252
+ console.error(`\u26A0 Adopted provisioned instance ${instanceId} but the platform returned no project` + (projectName ? ` \u2014 reports will use project '${projectName}'` : ` \u2014 reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`));
36218
36253
  } else {
36219
- console.error(`\u26A0 Could not adopt provisioned instance ${instanceId} \u2014 reports will use project '${projectName}' until 'postgresai mon local-install' is re-run`);
36254
+ console.error(`\u26A0 Could not adopt provisioned instance ${instanceId}` + (projectName ? ` \u2014 reports will use project '${projectName}' until 'postgresai mon local-install' is re-run` : ` \u2014 reports will have no project until 'postgresai mon local-install' is re-run with --project <name>`));
36220
36255
  }
36221
36256
  const aas = await registerAasCollection(apiKey, instanceId, {
36222
36257
  grafanaPassword,
@@ -36232,6 +36267,9 @@ Searched: ${demoCandidates.join(", ")}
36232
36267
  console.error(`\u26A0 AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'
36233
36268
  `);
36234
36269
  }
36270
+ } else if (plan.kind === "error-missing-project") {
36271
+ console.error("\u2717 A project name is required for self-registration (the 'postgres-ai-monitoring' default was removed). " + "Re-run with --project <name>, or adopt a console-provisioned instance with --instance-id <uuid>.");
36272
+ process.exitCode = 1;
36235
36273
  } else {
36236
36274
  registerMonitoringInstance(apiKey, projectName, {
36237
36275
  apiBaseUrl: globalOpts.apiBaseUrl,
@@ -37997,5 +38035,6 @@ export {
37997
38035
  registerMonitoringInstance,
37998
38036
  refreshBundledComposeIfStale,
37999
38037
  readDeployedTag,
38038
+ planMonitoringRegistration,
38000
38039
  isValidComposeYaml
38001
38040
  };
@@ -28,7 +28,11 @@ function grafanaBaseUrl(): string {
28
28
  }
29
29
 
30
30
  function grafanaAdminUser(): string {
31
- return process.env.GF_SECURITY_ADMIN_USER || "admin";
31
+ // The monitoring stack's compose hardcodes the Grafana admin user to
32
+ // "monitor" (GF_SECURITY_ADMIN_USER: monitor), so default to that rather than
33
+ // Grafana's stock "admin" — otherwise AAS arming logs in as the wrong user
34
+ // and every datasource lookup 401s. An explicit env override still wins.
35
+ return process.env.GF_SECURITY_ADMIN_USER || "monitor";
32
36
  }
33
37
 
34
38
  /** Parse a vcpus input (flag/env) to a non-negative integer; 0 = "unknown" fallback. */
@@ -130,17 +134,26 @@ export async function mintAasServiceAccountToken(
130
134
  * Resolve the single Prometheus-typed datasource's numeric id on the local
131
135
  * Grafana. The monitoring stack's VictoriaMetrics datasource is type
132
136
  * "prometheus" (VM speaks PromQL), and the stack registers exactly one such
133
- * datasource — the same one the collector queries. >1 or 0 → null (skip),
134
- * matching v1.aas_onboard's discovery contract.
137
+ * datasource — the same one the collector queries. 0 / API-not-ready → null
138
+ * (a provisioning transient — the readiness loop retries); >1 → "ambiguous"
139
+ * (a permanent misconfiguration — the loop stops at once), matching
140
+ * v1.aas_onboard's >1 skip.
135
141
  */
136
- export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | null> {
142
+ export async function resolveDatasourceId(adminPassword: string, debug = false): Promise<number | "ambiguous" | null> {
137
143
  try {
138
144
  const res = await grafanaApi("GET", "/api/datasources", adminPassword);
139
145
  if (!res.ok) return null;
140
146
  const list = (await res.json().catch(() => [])) as Array<{ id?: unknown; type?: unknown }>;
141
147
  const prom = list.filter((d) => d.type === "prometheus");
142
- if (prom.length !== 1) {
143
- if (debug) console.error(`Debug: AAS: expected 1 prometheus datasource, found ${prom.length}`);
148
+ if (prom.length > 1) {
149
+ // >1 is a permanent misconfiguration, not a provisioning transient: the
150
+ // datasource count only grows as Grafana provisions, so retrying can never
151
+ // resolve it. Signal a definitive skip so the readiness loop bails at once.
152
+ if (debug) console.error(`Debug: AAS: ${prom.length} prometheus datasources (ambiguous); not retrying`);
153
+ return "ambiguous";
154
+ }
155
+ if (prom.length === 0) {
156
+ if (debug) console.error(`Debug: AAS: no prometheus datasource resolvable yet`);
144
157
  return null;
145
158
  }
146
159
  return typeof prom[0].id === "number" ? prom[0].id : null;
@@ -169,6 +182,10 @@ export async function registerAasCollection(
169
182
  apiBaseUrl?: string;
170
183
  debug?: boolean;
171
184
  fetchImpl?: typeof fetch;
185
+ // Grafana-readiness polling for the datasource lookup (Grafana has just
186
+ // been started by `compose up`). Defaults: 20 attempts × 3s.
187
+ datasourceMaxAttempts?: number;
188
+ datasourceRetryDelayMs?: number;
172
189
  }
173
190
  ): Promise<AasRegisterResult> {
174
191
  const debug = !!opts.debug;
@@ -178,7 +195,24 @@ export async function registerAasCollection(
178
195
  const labels = resolveAasLabels(opts.instancesPath);
179
196
  if (!labels) return { ok: false, reason: "could not determine a single (cluster, node_name) target" };
180
197
 
181
- const datasourceId = await resolveDatasourceId(opts.grafanaPassword, debug);
198
+ // Grafana was just started by `compose up`; it needs time to create its
199
+ // admin user, provision datasources, and serve its API. Querying too early
200
+ // makes the datasource lookup fail transiently, so poll until it resolves
201
+ // (best-effort, capped — the install never blocks on this).
202
+ const maxAttempts = opts.datasourceMaxAttempts ?? 20;
203
+ const retryDelayMs = opts.datasourceRetryDelayMs ?? 3000;
204
+ let datasourceId: number | null = null;
205
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
206
+ const resolved = await resolveDatasourceId(opts.grafanaPassword, debug);
207
+ if (typeof resolved === "number") { datasourceId = resolved; break; }
208
+ // "ambiguous" (>1 prometheus datasource) is permanent — retrying can't fix
209
+ // it, so stop polling immediately instead of waiting out the whole budget.
210
+ if (resolved === "ambiguous") break;
211
+ if (attempt < maxAttempts) {
212
+ if (debug) console.error(`Debug: AAS: datasource not resolvable yet (attempt ${attempt}/${maxAttempts}); waiting for Grafana…`);
213
+ await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
214
+ }
215
+ }
182
216
  if (datasourceId == null) return { ok: false, reason: "could not resolve the Prometheus datasource id" };
183
217
 
184
218
  const saToken = await mintAasServiceAccountToken(opts.grafanaPassword, debug);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.16.0-dev.0",
3
+ "version": "0.16.0-dev.2",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,
@@ -198,6 +198,8 @@ describe("registerAasCollection", () => {
198
198
  installFetch({ prometheusCount: n });
199
199
  const r = await registerAasCollection("apikey-1", "inst-123", {
200
200
  grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
201
+ // 0/>1 is a definitive skip; cap the readiness retry so the test stays fast.
202
+ datasourceMaxAttempts: 2, datasourceRetryDelayMs: 0,
201
203
  });
202
204
  expect(r.ok).toBe(false);
203
205
  expect(r.reason).toContain("datasource");
@@ -205,6 +207,88 @@ describe("registerAasCollection", () => {
205
207
  }
206
208
  });
207
209
 
210
+ test("polls the datasource until Grafana is ready, then registers", async () => {
211
+ // Grafana isn't ready on the first probes (no prometheus datasource yet),
212
+ // then it provisions — the readiness retry must keep going and then succeed.
213
+ let dsProbes = 0;
214
+ calls = [];
215
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
216
+ const url = String(input);
217
+ const method = (init?.method || "GET").toUpperCase();
218
+ calls.push({ url, method, body: init?.body });
219
+ if (url.includes("/api/serviceaccounts/search")) return res(true, 200, { serviceAccounts: [] });
220
+ if (url.match(/\/tokens$/) && method === "POST") return res(true, 200, { key: "glsa_mock" });
221
+ if (url.endsWith("/api/serviceaccounts") && method === "POST") return res(true, 201, { id: 42 });
222
+ if (url.includes("/api/datasources")) {
223
+ dsProbes++;
224
+ return dsProbes < 3
225
+ ? res(true, 200, [{ id: 3, type: "loki" }]) // not ready yet
226
+ : res(true, 200, [{ id: 8, type: "prometheus" }, { id: 3, type: "loki" }]);
227
+ }
228
+ if (url.includes("/rpc/monitoring_instance_aas_register")) return res(true, 200, {});
229
+ return res(false, 404, {});
230
+ }) as unknown as typeof fetch);
231
+
232
+ const r = await registerAasCollection("apikey-1", "inst-123", {
233
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
234
+ datasourceMaxAttempts: 6, datasourceRetryDelayMs: 0,
235
+ });
236
+ expect(r.ok).toBe(true);
237
+ expect(dsProbes).toBeGreaterThanOrEqual(3); // kept polling past the not-ready probes
238
+ const rpc = calls.find((c) => c.url.includes("/rpc/monitoring_instance_aas_register"));
239
+ expect(rpc).toBeDefined();
240
+ expect(JSON.parse(rpc!.body!).datasource_id).toBe(8);
241
+ });
242
+
243
+ test(">1 prometheus datasource is a definitive skip: one probe, no retry", async () => {
244
+ // The >1 case is permanent (the datasource count only grows), so the
245
+ // readiness loop must bail after a single probe, not burn its whole budget.
246
+ let dsProbes = 0;
247
+ calls = [];
248
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
249
+ const url = String(input);
250
+ const method = (init?.method || "GET").toUpperCase();
251
+ calls.push({ url, method, body: init?.body });
252
+ if (url.includes("/api/datasources")) {
253
+ dsProbes++;
254
+ return res(true, 200, [{ id: 8, type: "prometheus" }, { id: 9, type: "prometheus" }, { id: 3, type: "loki" }]);
255
+ }
256
+ return res(false, 404, {});
257
+ }) as unknown as typeof fetch);
258
+
259
+ const r = await registerAasCollection("apikey-1", "inst-123", {
260
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
261
+ datasourceMaxAttempts: 5, datasourceRetryDelayMs: 0,
262
+ });
263
+ expect(r.ok).toBe(false);
264
+ expect(r.reason).toContain("datasource");
265
+ expect(dsProbes).toBe(1); // bailed after one probe; did NOT retry 5x
266
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
267
+ });
268
+
269
+ test("never-ready datasource: polls exactly maxAttempts times, then ok:false", async () => {
270
+ // Bounds the readiness loop: a never-appearing datasource must probe exactly
271
+ // maxAttempts times (N probes, N-1 sleeps) and then give up — not loop forever.
272
+ let dsProbes = 0;
273
+ calls = [];
274
+ fetchSpy = spyOn(globalThis, "fetch").mockImplementation((async (input: unknown, init?: { method?: string; body?: string }) => {
275
+ const url = String(input);
276
+ const method = (init?.method || "GET").toUpperCase();
277
+ calls.push({ url, method, body: init?.body });
278
+ if (url.includes("/api/datasources")) { dsProbes++; return res(true, 200, [{ id: 3, type: "loki" }]); } // never a prometheus
279
+ return res(false, 404, {});
280
+ }) as unknown as typeof fetch);
281
+
282
+ const r = await registerAasCollection("apikey-1", "inst-123", {
283
+ grafanaPassword: "pw", instancesPath, vcpus: 8, apiBaseUrl: "https://api.test",
284
+ datasourceMaxAttempts: 3, datasourceRetryDelayMs: 0,
285
+ });
286
+ expect(r.ok).toBe(false);
287
+ expect(r.reason).toContain("datasource");
288
+ expect(dsProbes).toBe(3); // bounded: exactly maxAttempts probes
289
+ expect(calls.some((c) => c.url.includes("/rpc/monitoring_instance_aas_register"))).toBe(false);
290
+ });
291
+
208
292
  test("mint returning no key → ok:false, no RPC call", async () => {
209
293
  installFetch({ mintKey: null });
210
294
  const r = await registerAasCollection("apikey-1", "inst-123", {
@@ -19,6 +19,7 @@ import {
19
19
  import {
20
20
  registerMonitoringInstance,
21
21
  resolveAdoptedProject,
22
+ planMonitoringRegistration,
22
23
  } from "../bin/postgres-ai";
23
24
 
24
25
  /**
@@ -241,12 +242,13 @@ describe("registerMonitoringInstance", () => {
241
242
  });
242
243
 
243
244
  // Issue platform-all#311: console-provisioned installs pass instance_id so
244
- // the platform adopts the provisioned instance instead of self-registering
245
- // a duplicate under an auto-created "postgres-ai-monitoring" project.
245
+ // the platform adopts the provisioned instance and returns its real project
246
+ // the CLI sends NO project_name on the adopt path (the hardcoded
247
+ // "postgres-ai-monitoring" default was removed).
246
248
  test("includes instance_id in body when adopting a provisioned instance", async () => {
247
249
  const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
248
250
 
249
- await registerMonitoringInstance("key", "postgres-ai-monitoring", opts({ instanceId }));
251
+ await registerMonitoringInstance("key", undefined, opts({ instanceId }));
250
252
 
251
253
  const body = JSON.parse(fetchCalls[0].options.body as string);
252
254
  expect(body.instance_id).toBe(instanceId);
@@ -255,6 +257,22 @@ describe("registerMonitoringInstance", () => {
255
257
  expect(headers["instance-id"]).toBeUndefined();
256
258
  });
257
259
 
260
+ test("omits project_name from the body when undefined (adopt path)", async () => {
261
+ await registerMonitoringInstance("key", undefined, opts({ instanceId: "i" }));
262
+
263
+ const body = JSON.parse(fetchCalls[0].options.body as string);
264
+ // The adopt path sends no name; the platform returns the real project.
265
+ expect("project_name" in body).toBe(false);
266
+ expect(body.api_token).toBe("key");
267
+ });
268
+
269
+ test("omits project_name from the body when empty/whitespace", async () => {
270
+ await registerMonitoringInstance("key", " ", opts({ instanceId: "i" }));
271
+
272
+ const body = JSON.parse(fetchCalls[0].options.body as string);
273
+ expect("project_name" in body).toBe(false);
274
+ });
275
+
258
276
  test("omits instance_id from the body for legacy self-registration", async () => {
259
277
  await registerMonitoringInstance("key", "my-project", opts());
260
278
 
@@ -262,6 +280,8 @@ describe("registerMonitoringInstance", () => {
262
280
  // PostgREST matches the 3-arg function via its default — the key must be
263
281
  // ABSENT (not null) so legacy CLIs and the new one hit the same overload.
264
282
  expect("instance_id" in body).toBe(false);
283
+ // A real project name still rides in the body for legacy registration.
284
+ expect(body.project_name).toBe("my-project");
265
285
  });
266
286
 
267
287
  test("a 200 with {project_id, project_name} returns a populated result", async () => {
@@ -323,6 +343,37 @@ describe("registerMonitoringInstance", () => {
323
343
  });
324
344
  });
325
345
 
346
+ describe("planMonitoringRegistration — mon local-install registration decision", () => {
347
+ test("instance id present → adopt, no project name required", () => {
348
+ const plan = planMonitoringRegistration({ instanceId: "i-123" });
349
+ expect(plan.kind).toBe("adopt");
350
+ expect(plan.projectName).toBeUndefined();
351
+ });
352
+
353
+ test("instance id present + project → adopt, carries the trimmed name", () => {
354
+ const plan = planMonitoringRegistration({ instanceId: "i-123", project: " prod-db " });
355
+ expect(plan.kind).toBe("adopt");
356
+ expect(plan.projectName).toBe("prod-db");
357
+ });
358
+
359
+ test("no instance id + no project → error-missing-project (exit 1 path)", () => {
360
+ const plan = planMonitoringRegistration({});
361
+ expect(plan.kind).toBe("error-missing-project");
362
+ expect(plan.projectName).toBeUndefined();
363
+ });
364
+
365
+ test("no instance id + empty/whitespace project → error-missing-project", () => {
366
+ expect(planMonitoringRegistration({ project: "" }).kind).toBe("error-missing-project");
367
+ expect(planMonitoringRegistration({ project: " " }).kind).toBe("error-missing-project");
368
+ });
369
+
370
+ test("no instance id + real project → legacy self-register with the trimmed name", () => {
371
+ const plan = planMonitoringRegistration({ project: " my-project " });
372
+ expect(plan.kind).toBe("self-register");
373
+ expect(plan.projectName).toBe("my-project");
374
+ });
375
+ });
376
+
326
377
  describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
327
378
  test("prefers the numeric project_id over the name (survives renames)", () => {
328
379
  expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");