postgresai 0.17.0-rc.2 → 0.17.0-rc.4

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/README.md CHANGED
@@ -271,6 +271,35 @@ postgresai mon check # System readiness check
271
271
  postgresai mon shell <service> # Open shell to monitoring service
272
272
  ```
273
273
 
274
+ ### PromQL queries (`promql`)
275
+
276
+ Ask a monitoring instance a PromQL question through the platform. The platform
277
+ never connects to the instance: it queues the query as a job, the instance picks
278
+ it up on its next poll, runs it against its own metric store, and posts the
279
+ answer back.
280
+
281
+ ```bash
282
+ # instant query
283
+ pgai promql 'up' --instance <instance-uuid>
284
+
285
+ # range query
286
+ pgai promql 'sum(rate(pgwatch_db_stats_xact_commit[5m]))' \
287
+ --instance <instance-uuid> \
288
+ --range --start 2026-09-21T00:00:00Z --end 2026-09-21T01:00:00Z --step 60
289
+
290
+ # on the box itself, --instance is read from .pgwatch-config
291
+ pgai promql 'up'
292
+ ```
293
+
294
+ `--at <time>` evaluates an instant query at a given moment (ignored with
295
+ `--range`). `--json` prints every point instead of the first and last per series.
296
+ The wait is sized from the instance's poll pacing; `--timeout` overrides it. A
297
+ result too large for the byte budget is trimmed and flagged `truncated`.
298
+
299
+ Requires platform-all !809
300
+ (https://gitlab.com/postgres-ai/platform-all/-/merge_requests/809); until it is
301
+ deployed the command returns a PGRST202 404.
302
+
274
303
  ### MCP server (`mcp` group)
275
304
 
276
305
  ```bash
@@ -299,7 +328,7 @@ required** — the server will not assume an organization. Call `orgs_list` (or
299
328
  run `pgai orgs`) to discover the available ids.
300
329
 
301
330
  Tools exposed:
302
- - `list_issues`: returns the same JSON as `postgresai issues list` (args: `{ org_id, status?, hidden_only?, limit?, offset?, debug? }`).
331
+ - `list_issues`: returns the same JSON as `postgresai issues list` (args: `{ org_id, status?, hidden_only?, limit?, offset?, debug? }`). Unlike the CLI, omitting `status` returns open and closed issues; pass `status: "open"` to match the CLI default.
303
332
  - `view_issue`: view a single issue with its comments (args: `{ issue_id, org_id, debug? }`).
304
333
  - `create_issue`: create a new issue (args: `{ title, description?, org_id, project_id?, labels?, attachments?, is_hidden?, debug? }`).
305
334
  - `update_issue`: update title/description/status/labels/is_hidden (args: `{ issue_id, org_id, title?, description?, status?, labels?, attachments?, is_hidden?, debug? }`).
@@ -350,7 +379,8 @@ sensitive.
350
379
  ### Issues management (`issues` group)
351
380
 
352
381
  ```bash
353
- postgresai issues list # List issues (shows: id, title, status, created_at; is_hidden only when set)
382
+ postgresai issues list # List OPEN issues (shows: id, title, status, created_at; is_hidden only when set)
383
+ postgresai issues list --status closed # Only closed issues (--status all for both)
354
384
  postgresai issues list --hidden-only # Only hidden issues (PostgresAI staff)
355
385
  postgresai issues view <issueId> # View issue details and comments
356
386
  postgresai issues create --org-id <id> <title> # Create a new issue
@@ -13430,6 +13430,11 @@ var require_dist2 = __commonJS(function(exports, module) {
13430
13430
  });
13431
13431
 
13432
13432
  // lib/util.ts
13433
+ function requestTimeoutSignal2(timeoutMs) {
13434
+ const requested = typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_HTTP_REQUEST_TIMEOUT_MS2;
13435
+ const bounded = Math.max(1, Math.min(DEFAULT_HTTP_REQUEST_TIMEOUT_MS2, Math.floor(requested)));
13436
+ return { signal: AbortSignal.timeout(bounded), timeoutMs: bounded };
13437
+ }
13433
13438
  function maskSecret2(secret) {
13434
13439
  if (!secret)
13435
13440
  return "";
@@ -13461,6 +13466,7 @@ function resolveBaseUrls2(opts, cfg, defaults = {}) {
13461
13466
  storageBaseUrl: normalizeBaseUrl2(storageCandidate)
13462
13467
  };
13463
13468
  }
13469
+ var DEFAULT_HTTP_REQUEST_TIMEOUT_MS2 = 25000;
13464
13470
  var init_util = () => {};
13465
13471
 
13466
13472
  // node_modules/commander/index.js
@@ -13524,7 +13530,7 @@ var {
13524
13530
  // package.json
13525
13531
  var package_default = {
13526
13532
  name: "postgresai",
13527
- version: "0.17.0-rc.2",
13533
+ version: "0.17.0-rc.4",
13528
13534
  description: "postgres_ai CLI",
13529
13535
  license: "Apache-2.0",
13530
13536
  private: false,
@@ -16884,7 +16890,7 @@ var Result = import_lib.default.Result;
16884
16890
  var TypeOverrides = import_lib.default.TypeOverrides;
16885
16891
  var defaults = import_lib.default.defaults;
16886
16892
  // package.json
16887
- var version = "0.17.0-rc.2";
16893
+ var version = "0.17.0-rc.4";
16888
16894
  var package_default2 = {
16889
16895
  name: "postgresai",
16890
16896
  version,
@@ -17217,6 +17223,22 @@ var TEXT_SENSITIVE_PAIR = /((?:password|passwd|db[-_]?pass|connstr|secret|token|
17217
17223
  function redactTextSecrets(text) {
17218
17224
  return text.replace(TEXT_URL_USERINFO, "$1:[REDACTED]@").replace(TEXT_SENSITIVE_PAIR, "$1[REDACTED]");
17219
17225
  }
17226
+ function isControlCodePoint(cp) {
17227
+ return cp < 32 || cp === 127 || cp >= 128 && cp <= 159 || cp === 8232 || cp === 8233;
17228
+ }
17229
+ function stripControlsToSpace(text) {
17230
+ let out = "";
17231
+ for (const ch of text) {
17232
+ const cp = ch.codePointAt(0);
17233
+ out += cp !== undefined && isControlCodePoint(cp) ? " " : ch;
17234
+ }
17235
+ return out;
17236
+ }
17237
+ function whitespaceTolerantSecretPattern(secret) {
17238
+ const chars = Array.from(secret.replace(/\s+/g, ""));
17239
+ const body = chars.map((c) => c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s*");
17240
+ return new RegExp(body, "g");
17241
+ }
17220
17242
  function normalizeBaseUrl(value) {
17221
17243
  const trimmed = (value || "").replace(/\/$/, "");
17222
17244
  try {
@@ -17315,6 +17337,10 @@ function resolveMcpOrgScope(args, apiKey, cfg) {
17315
17337
  }
17316
17338
 
17317
17339
  // lib/issues.ts
17340
+ var IssueStatus = {
17341
+ OPEN: 0,
17342
+ CLOSED: 1
17343
+ };
17318
17344
  function issueRequestHeaders(apiKey, orgScope) {
17319
17345
  return buildAuthHeaders(apiKey, orgScope, {
17320
17346
  Prefer: "return=representation",
@@ -17331,6 +17357,17 @@ function withVisibleHiddenFlag(issue) {
17331
17357
  const { is_hidden: _dropped, ...rest } = issue;
17332
17358
  return rest;
17333
17359
  }
17360
+ function presentIssue(issue) {
17361
+ const visible = withVisibleHiddenFlag(issue);
17362
+ return { ...visible, status: issueStatusLabel(issue.status) };
17363
+ }
17364
+ function issueStatusLabel(status) {
17365
+ if (status === IssueStatus.OPEN)
17366
+ return "open";
17367
+ if (status === IssueStatus.CLOSED)
17368
+ return "closed";
17369
+ return status;
17370
+ }
17334
17371
  async function fetchIssues(params) {
17335
17372
  const { apiKey, apiBaseUrl, orgScope, orgId, status, limit = 20, offset = 0, hiddenOnly, debug } = params;
17336
17373
  if (!apiKey) {
@@ -24994,7 +25031,7 @@ async function handleToolCall(req, rootOpts, extra) {
24994
25031
  const offset = args.offset !== undefined ? Number(args.offset) : undefined;
24995
25032
  const hiddenOnly = Boolean(args.hidden_only);
24996
25033
  const issues = await fetchIssues({ apiKey, apiBaseUrl, orgId, orgScope: scope.orgScope, status, limit, offset, hiddenOnly, debug });
24997
- const trimmed = issues.map((issue) => withVisibleHiddenFlag(issue));
25034
+ const trimmed = issues.map((issue) => presentIssue(issue));
24998
25035
  return { content: [{ type: "text", text: JSON.stringify(trimmed, null, 2) }] };
24999
25036
  }
25000
25037
  if (toolName === "view_issue") {
@@ -25010,7 +25047,7 @@ async function handleToolCall(req, rootOpts, extra) {
25010
25047
  return { content: [{ type: "text", text: "Issue not found" }], isError: true };
25011
25048
  }
25012
25049
  const comments = await fetchIssueComments({ apiKey, apiBaseUrl, issueId, orgScope: scope.orgScope, debug });
25013
- const combined = { issue: withVisibleHiddenFlag(issue), comments };
25050
+ const combined = { issue: presentIssue(issue), comments };
25014
25051
  return { content: [{ type: "text", text: JSON.stringify(combined, null, 2) }] };
25015
25052
  }
25016
25053
  if (toolName === "post_issue_comment") {
@@ -25314,7 +25351,7 @@ async function startMcpServer(rootOpts, extra) {
25314
25351
  type: "object",
25315
25352
  properties: {
25316
25353
  org_id: { type: "number", description: "Organization ID. Optional with a per-organization token (falls back to config); REQUIRED with a global token, which can reach every organization the user belongs to and will not assume one. Use orgs_list / `pgai orgs` to discover valid values." },
25317
- status: { type: "string", description: "Filter by status: 'open', 'closed', or omit for all" },
25354
+ status: { type: "string", description: "Filter by status: 'open', 'closed', or omit for all (note: the CLI defaults to open). Rows carry status as 'open'/'closed'." },
25318
25355
  limit: { type: "number", description: "Max number of issues to return (default: 20)" },
25319
25356
  offset: { type: "number", description: "Number of issues to skip (default: 0)" },
25320
25357
  hidden_only: { type: "boolean", description: "List only hidden issues (PostgresAI staff; same as CLI --hidden-only)" },
@@ -25596,6 +25633,10 @@ async function startMcpServer(rootOpts, extra) {
25596
25633
  }
25597
25634
 
25598
25635
  // lib/issues.ts
25636
+ var IssueStatus2 = {
25637
+ OPEN: 0,
25638
+ CLOSED: 1
25639
+ };
25599
25640
  function issueRequestHeaders2(apiKey, orgScope) {
25600
25641
  return buildAuthHeaders(apiKey, orgScope, {
25601
25642
  Prefer: "return=representation",
@@ -25612,6 +25653,17 @@ function withVisibleHiddenFlag2(issue) {
25612
25653
  const { is_hidden: _dropped, ...rest } = issue;
25613
25654
  return rest;
25614
25655
  }
25656
+ function presentIssue2(issue) {
25657
+ const visible = withVisibleHiddenFlag2(issue);
25658
+ return { ...visible, status: issueStatusLabel2(issue.status) };
25659
+ }
25660
+ function issueStatusLabel2(status) {
25661
+ if (status === IssueStatus2.OPEN)
25662
+ return "open";
25663
+ if (status === IssueStatus2.CLOSED)
25664
+ return "closed";
25665
+ return status;
25666
+ }
25615
25667
  async function fetchIssues2(params) {
25616
25668
  const { apiKey, apiBaseUrl, orgScope, orgId, status, limit = 20, offset = 0, hiddenOnly, debug } = params;
25617
25669
  if (!apiKey) {
@@ -27138,6 +27190,134 @@ async function destroySnapshot(params) {
27138
27190
  // bin/postgres-ai.ts
27139
27191
  init_util();
27140
27192
 
27193
+ // lib/promql.ts
27194
+ function describeRpcError(status, body) {
27195
+ try {
27196
+ const parsed = JSON.parse(body);
27197
+ const parts = [parsed.message, parsed.details, parsed.hint].filter(Boolean);
27198
+ if (parts.length)
27199
+ return parts.join(" — ");
27200
+ } catch {}
27201
+ return body.trim() || `HTTP ${status}`;
27202
+ }
27203
+ async function callRpc3(name, apiKey, body, opts) {
27204
+ const { apiBaseUrl } = resolveBaseUrls(opts);
27205
+ const res = await fetch(`${apiBaseUrl}/rpc/${name}`, {
27206
+ method: "POST",
27207
+ headers: {
27208
+ "Content-Type": "application/json",
27209
+ "access-token": apiKey,
27210
+ ...orgScopeHeaders(getActiveOrgScope())
27211
+ },
27212
+ body: JSON.stringify(body),
27213
+ signal: AbortSignal.timeout(opts?.timeoutMs ?? 30000)
27214
+ });
27215
+ const text = await res.text().catch(() => "");
27216
+ if (!res.ok) {
27217
+ throw new Error(describeRpcError(res.status, text));
27218
+ }
27219
+ try {
27220
+ return text ? JSON.parse(text) : null;
27221
+ } catch {
27222
+ throw new Error(`${name} returned a body that is not JSON`);
27223
+ }
27224
+ }
27225
+ async function enqueueQuery(apiKey, args, opts) {
27226
+ const body = {
27227
+ p_instance_id: args.instanceId,
27228
+ p_kind: args.kind,
27229
+ p_query: args.query
27230
+ };
27231
+ if (args.kind === "promql_range") {
27232
+ body.p_start = args.start;
27233
+ body.p_end = args.end;
27234
+ body.p_step_s = args.stepS;
27235
+ } else if (args.at) {
27236
+ body.p_at = args.at;
27237
+ }
27238
+ const reply = await callRpc3("instance_query_enqueue", apiKey, body, opts);
27239
+ const jobId = reply?.job_id;
27240
+ if (!jobId) {
27241
+ throw new Error("instance_query_enqueue returned no job_id");
27242
+ }
27243
+ return { jobId, firstAnswerEstimateS: positiveSeconds(reply?.first_answer_estimate_s) };
27244
+ }
27245
+ function positiveSeconds(v) {
27246
+ return typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
27247
+ }
27248
+ async function readQueryResult(apiKey, jobId, opts) {
27249
+ const reply = await callRpc3("instance_query_result", apiKey, { p_job_id: jobId }, opts);
27250
+ if (!reply || typeof reply.status !== "string") {
27251
+ throw new Error("instance_query_result returned an unexpected reply");
27252
+ }
27253
+ return reply;
27254
+ }
27255
+ var TERMINAL = new Set(["done", "failed", "expired"]);
27256
+ function isTerminal2(status) {
27257
+ return TERMINAL.has(status);
27258
+ }
27259
+ function pollDelay(attempt) {
27260
+ return Math.min(1000 * 2 ** attempt, 15000);
27261
+ }
27262
+ async function awaitQueryResult(apiKey, jobId, opts) {
27263
+ const now = opts.now ?? (() => Date.now());
27264
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
27265
+ const deadline = now() + opts.timeoutMs;
27266
+ for (let attempt = 0;; attempt++) {
27267
+ const res = await readQueryResult(apiKey, jobId, opts);
27268
+ if (isTerminal2(res.status))
27269
+ return res;
27270
+ const delay = pollDelay(attempt);
27271
+ if (now() + delay >= deadline)
27272
+ return res;
27273
+ await sleep(delay);
27274
+ }
27275
+ }
27276
+ function formatMetric(metric) {
27277
+ const name = metric.__name__ ?? "";
27278
+ const labels = Object.entries(metric).filter(([k]) => k !== "__name__").sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}="${v}"`);
27279
+ if (!labels.length)
27280
+ return name || "{}";
27281
+ return `${name}{${labels.join(", ")}}`;
27282
+ }
27283
+ function pad(s, width) {
27284
+ return s.length >= width ? s : s + " ".repeat(width - s.length);
27285
+ }
27286
+ function renderPromQL(payload) {
27287
+ const lines = [];
27288
+ if (!payload.result.length) {
27289
+ lines.push("Empty result set.");
27290
+ } else if (payload.resultType === "vector") {
27291
+ const rows = payload.result.map((s) => [formatMetric(s.metric), s.value ? s.value[1] : ""]);
27292
+ const w = Math.max(6, ...rows.map((r) => r[0].length));
27293
+ lines.push(`${pad("SERIES", w)} VALUE`);
27294
+ for (const [m, v] of rows)
27295
+ lines.push(`${pad(m, w)} ${v}`);
27296
+ } else {
27297
+ const rows = payload.result.map((s) => {
27298
+ const vs = s.values ?? [];
27299
+ const first = vs.length ? vs[0][1] : "";
27300
+ const last = vs.length ? vs[vs.length - 1][1] : "";
27301
+ return [formatMetric(s.metric), String(vs.length), first, last];
27302
+ });
27303
+ const w = Math.max(6, ...rows.map((r) => r[0].length));
27304
+ lines.push(`${pad("SERIES", w)} POINTS FIRST LAST`);
27305
+ for (const [m, n, f, l] of rows)
27306
+ lines.push(`${pad(m, w)} ${pad(n, 6)} ${f} ${l}`);
27307
+ if (rows.length)
27308
+ lines.push("(first and last sample per series; use --json for every point)");
27309
+ }
27310
+ const series = payload.result.length;
27311
+ const points = payload.result.reduce((n, s) => n + (s.values?.length ?? 0) + (s.value ? 1 : 0), 0);
27312
+ lines.push(`
27313
+ ${series} series, ${points} points`);
27314
+ if (payload.stats.truncated) {
27315
+ lines.push("⚠ TRUNCATED: too large to return whole. Raise --step, narrow the time range, " + "or select fewer series.");
27316
+ }
27317
+ return lines.join(`
27318
+ `);
27319
+ }
27320
+
27141
27321
  // lib/instances.ts
27142
27322
  import * as fs4 from "fs";
27143
27323
 
@@ -30402,6 +30582,38 @@ async function resolveDatasourceId(adminPassword, debug = false) {
30402
30582
  return null;
30403
30583
  }
30404
30584
  }
30585
+ function aasSuccessMessage(r) {
30586
+ const armed = r.armedFor === "pull" ? " (armed for pull: the platform will query this instance's Grafana)" : r.armedFor === "jobs" ? " (armed for the job channel: this instance answers its own queries)" : "";
30587
+ const base = `AAS auto-collection registered${armed}`;
30588
+ if (r.vcpus === 0) {
30589
+ return `${base} — collection stays OFF until a source-DB vCPU count is known (the platform stamps it at provision time; --vcpus <n> sets it for a manual install)`;
30590
+ }
30591
+ if (r.urlPending) {
30592
+ return `${base} — collection starts once this deploy reports the Grafana url to the platform`;
30593
+ }
30594
+ return base;
30595
+ }
30596
+ var PLATFORM_ERROR_MAX = 300;
30597
+ function formatPlatformError(body, secrets = []) {
30598
+ if (!body || typeof body !== "object")
30599
+ return "";
30600
+ const b = body;
30601
+ const str = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : "";
30602
+ const text = str(b.details) || str(b.message);
30603
+ if (!text)
30604
+ return "";
30605
+ const code = str(b.code);
30606
+ let out = stripControlsToSpace(code ? `${code}: ${text}` : text).replace(/\s+/g, " ").trim();
30607
+ for (const secret of secrets) {
30608
+ const compact = secret.replace(/\s+/g, "");
30609
+ if (compact.length < 8)
30610
+ continue;
30611
+ out = out.replace(whitespaceTolerantSecretPattern(compact), "[redacted]");
30612
+ }
30613
+ out = redactTextSecrets(out);
30614
+ out = out.replace(/glsa_[A-Za-z0-9_-]+/g, "[redacted]");
30615
+ return out.length > PLATFORM_ERROR_MAX ? `${out.slice(0, PLATFORM_ERROR_MAX - 1).trimEnd()}…` : out;
30616
+ }
30405
30617
  async function registerAasCollection(apiKey, instanceId, opts) {
30406
30618
  const debug = !!opts.debug;
30407
30619
  try {
@@ -30451,11 +30663,19 @@ async function registerAasCollection(apiKey, instanceId, opts) {
30451
30663
  })
30452
30664
  });
30453
30665
  if (!res.ok) {
30666
+ const detail = formatPlatformError(await res.json().catch(() => null), [apiKey, saToken]);
30667
+ const suffix = detail ? ` — ${detail}` : "";
30454
30668
  if (debug)
30455
- console.error(`Debug: AAS register failed: HTTP ${res.status}`);
30456
- return { ok: false, reason: `platform returned HTTP ${res.status}` };
30669
+ console.error(`Debug: AAS register failed: HTTP ${res.status}${suffix}`);
30670
+ return { ok: false, reason: `platform returned HTTP ${res.status}${suffix}` };
30457
30671
  }
30458
- return { ok: true };
30672
+ const reply = await res.json().catch(() => null);
30673
+ return {
30674
+ ok: true,
30675
+ vcpus: typeof reply?.vcpus === "number" ? reply.vcpus : undefined,
30676
+ urlPending: typeof reply?.url_pending === "boolean" ? reply.url_pending : undefined,
30677
+ armedFor: typeof reply?.armed_for === "string" ? reply.armed_for : undefined
30678
+ };
30459
30679
  } catch (err) {
30460
30680
  if (debug)
30461
30681
  console.error(`Debug: AAS register error: ${err.message}`);
@@ -38784,9 +39004,62 @@ var LOCAL_INSTALL_MANAGED_ENV_KEYS = [
38784
39004
  "VM_DELETE_AUTH_KEY",
38785
39005
  "VM_SNAPSHOT_AUTH_KEY",
38786
39006
  "VM_FORCE_MERGE_AUTH_KEY",
38787
- "VM_PPROF_AUTH_KEY"
39007
+ "VM_PPROF_AUTH_KEY",
39008
+ "PGAI_INSTANCE_ID",
39009
+ "INSTANCE_JOBS_USER",
39010
+ "COMPOSE_PROFILES"
38788
39011
  ];
38789
- function buildLocalInstallEnv(existingEnv, imageTag) {
39012
+ var INSTANCE_JOBS_PROFILE = "instance-jobs";
39013
+ function composeProfilesValue(existing, want) {
39014
+ const profiles = [];
39015
+ for (const raw of (existing ?? "").split(",")) {
39016
+ const token = raw.trim();
39017
+ if (token && !profiles.includes(token))
39018
+ profiles.push(token);
39019
+ }
39020
+ const at = profiles.indexOf(INSTANCE_JOBS_PROFILE);
39021
+ if (want === true && at === -1)
39022
+ profiles.push(INSTANCE_JOBS_PROFILE);
39023
+ if (want === false && at !== -1)
39024
+ profiles.splice(at, 1);
39025
+ return profiles.length > 0 ? profiles.join(",") : null;
39026
+ }
39027
+ function instanceJobsProfileEnabled(composeProfiles) {
39028
+ return (composeProfiles ?? "").split(",").map((p) => p.trim()).includes(INSTANCE_JOBS_PROFILE);
39029
+ }
39030
+ function resolveInstanceJobsRequest(flag, envValue) {
39031
+ if (flag !== undefined)
39032
+ return { enabled: flag };
39033
+ const value = (envValue ?? "").trim().toLowerCase();
39034
+ if (value === "")
39035
+ return {};
39036
+ if (["1", "true", "yes", "on"].includes(value))
39037
+ return { enabled: true };
39038
+ if (["0", "false", "no", "off"].includes(value))
39039
+ return { enabled: false };
39040
+ return {
39041
+ error: `PGAI_INSTANCE_JOBS=${envValue} is not a boolean ` + "(use true/false, 1/0, yes/no, on/off, or leave it empty to keep the current setting)"
39042
+ };
39043
+ }
39044
+ function statOwner(file) {
39045
+ if (!file)
39046
+ return;
39047
+ let st;
39048
+ try {
39049
+ st = fs9.statSync(file);
39050
+ } catch {
39051
+ return;
39052
+ }
39053
+ if (!st.isFile())
39054
+ return null;
39055
+ return `${st.uid}:${st.gid}`;
39056
+ }
39057
+ function processOwner() {
39058
+ if (typeof process.getuid !== "function" || typeof process.getgid !== "function")
39059
+ return null;
39060
+ return `${process.getuid()}:${process.getgid()}`;
39061
+ }
39062
+ function buildLocalInstallEnv(existingEnv, imageTag, instanceId, credentialFile, instanceJobs) {
38790
39063
  const readRaw = (key) => {
38791
39064
  const re = new RegExp(`^[ ]*(?:export[ ]+)?${key}=(.*)$`, "gm");
38792
39065
  let value = null;
@@ -38810,6 +39083,18 @@ function buildLocalInstallEnv(existingEnv, imageTag) {
38810
39083
  envLines.push(`VM_AUTH_USERNAME=${vmAuthUsername ? stripMatchingQuotes(vmAuthUsername) : "vmauth"}`);
38811
39084
  const vmAuthPassword = readRaw("VM_AUTH_PASSWORD");
38812
39085
  envLines.push(`VM_AUTH_PASSWORD=${vmAuthPassword ? stripMatchingQuotes(vmAuthPassword) : crypto2.randomBytes(18).toString("base64")}`);
39086
+ const instanceIdValue = instanceId?.trim() || readRaw("PGAI_INSTANCE_ID");
39087
+ if (instanceIdValue) {
39088
+ envLines.push(`PGAI_INSTANCE_ID=${stripMatchingQuotes(instanceIdValue)}`);
39089
+ }
39090
+ const owner = statOwner(credentialFile);
39091
+ const credentialOwner = owner === undefined ? processOwner() : owner;
39092
+ if (credentialOwner)
39093
+ envLines.push(`INSTANCE_JOBS_USER=${credentialOwner}`);
39094
+ const existingProfiles = readRaw("COMPOSE_PROFILES");
39095
+ const composeProfiles = composeProfilesValue(existingProfiles === null ? null : stripMatchingQuotes(existingProfiles), instanceJobs);
39096
+ if (composeProfiles)
39097
+ envLines.push(`COMPOSE_PROFILES=${composeProfiles}`);
38813
39098
  for (const key of VM_ADMIN_AUTH_KEYS) {
38814
39099
  const existing = readRaw(key);
38815
39100
  envLines.push(`${key}=${existing ? stripMatchingQuotes(existing) : crypto2.randomBytes(32).toString("hex")}`);
@@ -40598,16 +40883,6 @@ function checkRunningContainers() {
40598
40883
  return { running: false, containers: [] };
40599
40884
  }
40600
40885
  }
40601
- function planMonitoringRegistration(args) {
40602
- const projectName = args.project?.trim() || undefined;
40603
- if (args.instanceId) {
40604
- return { kind: "adopt", projectName };
40605
- }
40606
- if (!projectName) {
40607
- return { kind: "error-missing-project", projectName: undefined };
40608
- }
40609
- return { kind: "self-register", projectName };
40610
- }
40611
40886
  async function registerMonitoringInstance(apiKey, projectName, opts) {
40612
40887
  const { apiBaseUrl } = resolveBaseUrls2(opts);
40613
40888
  const url = `${apiBaseUrl}/rpc/monitoring_instance_register`;
@@ -40642,7 +40917,8 @@ Debug: Registering monitoring instance...`);
40642
40917
  "Content-Type": "application/json",
40643
40918
  ...orgScopeHeaders2(opts?.orgScope)
40644
40919
  },
40645
- body: JSON.stringify(requestBody)
40920
+ body: JSON.stringify(requestBody),
40921
+ signal: requestTimeoutSignal2(opts?.timeoutMs).signal
40646
40922
  });
40647
40923
  const body = await res.text().catch(() => "");
40648
40924
  if (!res.ok) {
@@ -40677,6 +40953,25 @@ Debug: Registering monitoring instance...`);
40677
40953
  }
40678
40954
  return null;
40679
40955
  }
40956
+ var INSTANCE_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
40957
+ function instanceIdToPersist(reg, requestedId) {
40958
+ for (const candidate of [reg?.instanceId, requestedId]) {
40959
+ const value = candidate?.trim();
40960
+ if (value && INSTANCE_ID_RE.test(value))
40961
+ return value;
40962
+ }
40963
+ return null;
40964
+ }
40965
+ function planMonitoringRegistration(args) {
40966
+ const projectName = args.project?.trim() || undefined;
40967
+ if (args.instanceId) {
40968
+ return { kind: "adopt", projectName };
40969
+ }
40970
+ if (!projectName) {
40971
+ return { kind: "error-missing-project", projectName: undefined };
40972
+ }
40973
+ return { kind: "self-register", projectName };
40974
+ }
40680
40975
  var PROJECT_NAME_RE = /^[A-Za-z0-9._-]+$/;
40681
40976
  function resolveAdoptedProject(reg) {
40682
40977
  if (!reg)
@@ -40693,8 +40988,11 @@ function updatePgwatchConfig(configPath, updates) {
40693
40988
  let lines = [];
40694
40989
  if (fs9.existsSync(configPath)) {
40695
40990
  const stats = fs9.statSync(configPath);
40696
- if (!stats.isDirectory()) {
40697
- const content = fs9.readFileSync(configPath, "utf8");
40991
+ if (!stats.isFile()) {
40992
+ throw new Error(`${configPath} exists but is not a regular file; refusing to write through it`);
40993
+ }
40994
+ {
40995
+ const content = fs9.readFileSync(configPath, "utf8").replace(/^\uFEFF+/, "");
40698
40996
  lines = content.split(/\r?\n/).filter((l) => l.trim() !== "");
40699
40997
  }
40700
40998
  }
@@ -40702,6 +41000,7 @@ function updatePgwatchConfig(configPath, updates) {
40702
41000
  const existingIndex = lines.findIndex((l) => l.startsWith(key + "="));
40703
41001
  if (existingIndex >= 0) {
40704
41002
  lines[existingIndex] = `${key}=${value}`;
41003
+ lines = lines.filter((l, i) => i === existingIndex || !l.startsWith(key + "="));
40705
41004
  } else {
40706
41005
  lines.push(`${key}=${value}`);
40707
41006
  }
@@ -40716,6 +41015,129 @@ function updatePgwatchConfig(configPath, updates) {
40716
41015
  console.error(`Warning: could not restrict permissions on ${configPath}: ${message}`);
40717
41016
  }
40718
41017
  }
41018
+ function applyApiKey(projectDir, apiKey) {
41019
+ const configPath = path7.resolve(projectDir, ".pgwatch-config");
41020
+ try {
41021
+ updatePgwatchConfig(configPath, { api_key: apiKey });
41022
+ } catch (err) {
41023
+ console.error(`\u2717 ${err instanceof Error ? err.message : String(err)}`);
41024
+ return false;
41025
+ }
41026
+ console.log(`\u2713 API key saved
41027
+ `);
41028
+ return true;
41029
+ }
41030
+ var DEFAULT_PROMQL_TIMEOUT_S = 11 * 60;
41031
+ function formatSeconds(total) {
41032
+ const s = Math.max(0, Math.round(total));
41033
+ if (s < 90)
41034
+ return `${s}s`;
41035
+ const m = Math.round(s / 60);
41036
+ if (m < 60)
41037
+ return `${m}m`;
41038
+ const h = Math.floor(m / 60);
41039
+ const rem = m % 60;
41040
+ return rem ? `${h}h${rem}m` : `${h}h`;
41041
+ }
41042
+ function parseDurationSeconds(v) {
41043
+ const m = /^(\d+)(s|m|h)?$/.exec(v.trim());
41044
+ if (!m)
41045
+ return null;
41046
+ const n = parseInt(m[1], 10);
41047
+ if (!Number.isFinite(n))
41048
+ return null;
41049
+ return n * (m[2] === "h" ? 3600 : m[2] === "m" ? 60 : 1);
41050
+ }
41051
+ function readInstanceIdFromProject() {
41052
+ try {
41053
+ const { projectDir } = resolvePaths();
41054
+ const configPath = path7.resolve(projectDir, ".pgwatch-config");
41055
+ if (!fs9.existsSync(configPath))
41056
+ return null;
41057
+ const line = fs9.readFileSync(configPath, "utf8").replace(/^\uFEFF+/, "").split(/\r?\n/).find((l) => l.startsWith("instance_id="));
41058
+ const value = line?.slice("instance_id=".length).trim();
41059
+ return value && INSTANCE_ID_RE.test(value) ? value : null;
41060
+ } catch {
41061
+ return null;
41062
+ }
41063
+ }
41064
+ function applyProjectName(projectDir, project) {
41065
+ const configPath = path7.resolve(projectDir, ".pgwatch-config");
41066
+ try {
41067
+ updatePgwatchConfig(configPath, { project_name: project });
41068
+ } catch (err) {
41069
+ console.error(`\u2717 ${err instanceof Error ? err.message : String(err)}`);
41070
+ return false;
41071
+ }
41072
+ console.log(`Using project name: ${project}
41073
+ `);
41074
+ return true;
41075
+ }
41076
+ function persistInstanceId(projectDir, reg, requestedId, alsoWrite = {}) {
41077
+ const instanceId = instanceIdToPersist(reg, requestedId);
41078
+ const updates = { ...alsoWrite, ...instanceId ? { instance_id: instanceId } : {} };
41079
+ if (Object.keys(updates).length === 0)
41080
+ return false;
41081
+ const configPath = path7.resolve(projectDir, ".pgwatch-config");
41082
+ try {
41083
+ updatePgwatchConfig(configPath, updates);
41084
+ return instanceId != null;
41085
+ } catch (err) {
41086
+ const message = err instanceof Error ? err.message : String(err);
41087
+ console.error(`Warning: could not record the monitoring instance id in ${configPath}: ${message}`);
41088
+ return false;
41089
+ }
41090
+ }
41091
+ async function adoptAndPersist(projectDir, apiKey, projectName, instanceId, opts, register = registerMonitoringInstance) {
41092
+ const reg = await register(apiKey, projectName, {
41093
+ orgScope: opts.orgScope,
41094
+ apiBaseUrl: opts.apiBaseUrl,
41095
+ debug: opts.debug,
41096
+ instanceId
41097
+ });
41098
+ const adoptedProject = resolveAdoptedProject(reg);
41099
+ const persisted = persistInstanceId(projectDir, reg, instanceId, {
41100
+ ...adoptedProject != null ? { project_name: adoptedProject } : {}
41101
+ });
41102
+ if (!persisted) {
41103
+ console.error(`\u26A0 Adopted instance ${instanceId} but could not record it in .pgwatch-config \u2014 ` + `instance jobs will idle until that file is writable and local-install is re-run
41104
+ `);
41105
+ }
41106
+ if (adoptedProject != null) {
41107
+ const verb = reg?.created ? "Registered" : "Adopted";
41108
+ console.log(`\u2713 ${verb} monitoring instance (project: ${adoptedProject})
41109
+ `);
41110
+ } else if (reg) {
41111
+ 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>`));
41112
+ } else {
41113
+ 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>`));
41114
+ }
41115
+ return reg;
41116
+ }
41117
+ async function selfRegisterAndPersist(projectDir, apiKey, projectName, opts, register = registerMonitoringInstance) {
41118
+ const reg = await register(apiKey, projectName, {
41119
+ apiBaseUrl: opts.apiBaseUrl,
41120
+ debug: opts.debug,
41121
+ orgScope: opts.orgScope
41122
+ });
41123
+ const persisted = persistInstanceId(projectDir, reg, undefined);
41124
+ if (persisted) {
41125
+ console.log(`\u2713 Registered monitoring instance (project: ${projectName})
41126
+ `);
41127
+ return reg;
41128
+ }
41129
+ const minted = reg?.instanceId?.trim();
41130
+ if (minted) {
41131
+ console.error(`\u26A0 Registered monitoring instance ${minted} but could not record it in .pgwatch-config \u2014 ` + `instance jobs will idle. Add 'instance_id=${minted}' to that file, or re-run with ` + `--instance-id ${minted} (which adopts it rather than registering a second one)
41132
+ `);
41133
+ } else {
41134
+ const reRun = projectName ? `'postgresai mon local-install --project ${projectName}'` : "'postgresai mon local-install --project <name>'";
41135
+ console.error(`\u26A0 Could not register this monitoring instance \u2014 instance jobs will idle until ` + `${reRun} is re-run
41136
+ `);
41137
+ }
41138
+ process.exitCode = 1;
41139
+ return reg;
41140
+ }
40719
41141
  async function applyMonitoringTargetsConfig() {
40720
41142
  console.log("Applying monitoring target configuration...");
40721
41143
  const generateCode = await runCompose(["run", "--rm", "sources-generator"]);
@@ -40724,7 +41146,7 @@ async function applyMonitoringTargetsConfig() {
40724
41146
  console.log("Restarting pgwatch collectors to pick up target changes...");
40725
41147
  return runCompose(["up", "-d", "--force-recreate", "pgwatch-prometheus", "pgwatch-postgres"]);
40726
41148
  }
40727
- async function runCompose(args, grafanaPassword) {
41149
+ async function runCompose(args, grafanaPassword, extraEnv) {
40728
41150
  let composeFile;
40729
41151
  let projectDir;
40730
41152
  try {
@@ -40788,6 +41210,8 @@ async function runCompose(args, grafanaPassword) {
40788
41210
  }
40789
41211
  }
40790
41212
  }
41213
+ if (extraEnv)
41214
+ Object.assign(env, extraEnv);
40791
41215
  const finalArgs = [...args];
40792
41216
  if (process.platform === "darwin" && shouldScaleOutNodeExporter(args)) {
40793
41217
  finalArgs.push("--scale", "self-node-exporter=0");
@@ -40846,6 +41270,127 @@ Applying sink-prometheus config (VictoriaMetrics admin keys, #359)...`);
40846
41270
  console.log("\u2713 sink-prometheus is running the current config");
40847
41271
  return true;
40848
41272
  }
41273
+ function readComposeProfiles(knownProjectDir) {
41274
+ const fromProcess = process.env.COMPOSE_PROFILES;
41275
+ if (fromProcess !== undefined && fromProcess.trim() !== "")
41276
+ return fromProcess;
41277
+ let projectDir;
41278
+ if (knownProjectDir) {
41279
+ projectDir = knownProjectDir;
41280
+ } else {
41281
+ try {
41282
+ ({ projectDir } = resolvePaths());
41283
+ } catch {
41284
+ projectDir = getDefaultMonitoringProjectDir();
41285
+ }
41286
+ }
41287
+ const envFile = path7.resolve(projectDir, ".env");
41288
+ let content;
41289
+ try {
41290
+ content = fs9.readFileSync(envFile, "utf8");
41291
+ } catch (err) {
41292
+ if (err?.code !== "ENOENT") {
41293
+ console.error(`\u26A0 Could not read ${envFile}: ${err instanceof Error ? err.message : String(err)}`);
41294
+ console.error(" Treating the instance-jobs profile as disabled; if it is in fact enabled,");
41295
+ console.error(" an absent container will be reported as '- not enabled' rather than a fault.");
41296
+ }
41297
+ return null;
41298
+ }
41299
+ let value = null;
41300
+ for (const m of content.matchAll(/^[ \t]*(?:export[ \t]+)?COMPOSE_PROFILES=(.*)$/gm)) {
41301
+ value = stripMatchingQuotes(m[1].trim());
41302
+ }
41303
+ return value;
41304
+ }
41305
+ function instanceJobsRemovalOutcome(before, after, removeExitCode) {
41306
+ if (before === "unknown" || after === "unknown") {
41307
+ return {
41308
+ ok: false,
41309
+ message: `\u2717 Could not determine whether the instance-jobs container is present.
41310
+ ` + " The profile is off in .env, but a container left behind keeps polling."
41311
+ };
41312
+ }
41313
+ if (before === "absent") {
41314
+ return { ok: true, message: "\u2713 instance-jobs is disabled (no container was present)" };
41315
+ }
41316
+ if (removeExitCode !== 0 || after !== "absent") {
41317
+ return {
41318
+ ok: false,
41319
+ message: `\u2717 Could not remove the instance-jobs container.
41320
+ ` + ` The profile is off in .env, but the container is still there and still polling. To retry:
41321
+ ` + ` COMPOSE_PROFILES=${INSTANCE_JOBS_PROFILE} docker compose rm -sf ${INSTANCE_JOBS_PROFILE}`
41322
+ };
41323
+ }
41324
+ return before === "running" ? {
41325
+ ok: true,
41326
+ message: "\u26A0 instance-jobs was RUNNING and has been removed \u2014 outbound collection on this machine is now OFF"
41327
+ } : { ok: true, message: "\u2713 instance-jobs container removed (it was not running)" };
41328
+ }
41329
+ function instanceJobsStartSkipReason(storeRunning) {
41330
+ if (storeRunning === true)
41331
+ return null;
41332
+ return storeRunning === false ? "the stack is not running, so instance-jobs was not started - it comes up with the rest on `mon start`" : "could not tell whether the stack is running, so instance-jobs was not started - run `mon start`, then `mon health`";
41333
+ }
41334
+ async function composeServiceRunning(projectDir, service) {
41335
+ const composeCmd = getComposeCmd();
41336
+ if (!composeCmd)
41337
+ return null;
41338
+ try {
41339
+ const { stdout } = await execFilePromise(composeCmd[0], [...composeCmd.slice(1), "-f", path7.resolve(projectDir, "docker-compose.yml"), "ps", "-q", service], { cwd: projectDir });
41340
+ return stdout.trim().length > 0;
41341
+ } catch {
41342
+ return null;
41343
+ }
41344
+ }
41345
+ async function applyInstanceJobsProfile(projectDir, enabled) {
41346
+ if (enabled === undefined)
41347
+ return true;
41348
+ if (enabled) {
41349
+ const skip = instanceJobsStartSkipReason(await composeServiceRunning(projectDir, "sink-prometheus"));
41350
+ if (skip) {
41351
+ console.log(`
41352
+ (${skip})`);
41353
+ return true;
41354
+ }
41355
+ console.log(`
41356
+ Starting the instance-jobs container (compose profile enabled)...`);
41357
+ const code = await runCompose(["up", "-d", "--no-deps", INSTANCE_JOBS_PROFILE]);
41358
+ if (code !== 0) {
41359
+ console.error("\u2717 Could not start the instance-jobs container.");
41360
+ console.error(" The channel is enabled in .env but nothing is polling. To retry:");
41361
+ console.error(` docker compose up -d --no-deps ${INSTANCE_JOBS_PROFILE}`);
41362
+ return false;
41363
+ }
41364
+ console.log("\u2713 instance-jobs is running");
41365
+ return true;
41366
+ }
41367
+ const before = await inspectInstanceJobsState();
41368
+ if (before === "absent" || before === "unknown") {
41369
+ const verdict = instanceJobsRemovalOutcome(before, before, 0);
41370
+ (verdict.ok ? console.log : console.error)(`
41371
+ ${verdict.message}`);
41372
+ return verdict.ok;
41373
+ }
41374
+ const code = await runCompose(["rm", "-sf", INSTANCE_JOBS_PROFILE], undefined, { COMPOSE_PROFILES: INSTANCE_JOBS_PROFILE });
41375
+ const verdict = instanceJobsRemovalOutcome(before, await inspectInstanceJobsState(), code);
41376
+ (verdict.ok ? console.log : console.error)(`
41377
+ ${verdict.message}`);
41378
+ return verdict.ok;
41379
+ }
41380
+ async function inspectInstanceJobsState() {
41381
+ try {
41382
+ const { stdout } = await execFilePromise("docker", [
41383
+ "inspect",
41384
+ "-f",
41385
+ "{{.State.Running}}",
41386
+ INSTANCE_JOBS_PROFILE
41387
+ ]);
41388
+ return stdout.trim() === "true" ? "running" : "stopped";
41389
+ } catch (error) {
41390
+ const text = error instanceof Error ? error.message : String(error);
41391
+ return /no such (object|container)/i.test(text) ? "absent" : "unknown";
41392
+ }
41393
+ }
40849
41394
  program2.command("help").description("show help").action(() => {
40850
41395
  program2.outputHelp();
40851
41396
  });
@@ -40859,7 +41404,7 @@ mon.command("local-install").description("install local monitoring stack (genera
40859
41404
  " PGAI_ENABLE_IPV6=false (accepted: true|false|yes|no, lowercase)",
40860
41405
  ""
40861
41406
  ].join(`
40862
- `)).option("--demo", "demo mode with sample database", false).option("--api-key <key>", "Postgres AI API key for automated report uploads").option("--org <alias>", `organization alias (or ${ORG_ENV}); required to register with a global token`).option("--org-id <id>", `organization id (or ${ORG_ID_ENV}); alternative to --org`).option("--db-url <url>", "PostgreSQL connection URL to monitor").option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)").option("--project <name>", "Docker Compose project name (default: postgres_ai)").option("--instance-id <uuid>", "adopt a console-provisioned monitoring instance instead of self-registering a new one (set automatically by the provisioning flow; PGAI_INSTANCE_ID env also works)").option("--vcpus <n>", "source DB vCPU count used for AAS zone thresholds (PGAI_VCPUS env also works). Omit or 0 = unknown: the platform keeps the value it stamped at provision time. An explicit non-zero value overwrites the stored one; changing it re-arms the AAS backfill.").option("-y, --yes", "accept all defaults and skip interactive prompts", false).action(async (opts) => {
41407
+ `)).option("--demo", "demo mode with sample database", false).option("--api-key <key>", "Postgres AI API key for automated report uploads").option("--org <alias>", `organization alias (or ${ORG_ENV}); required to register with a global token`).option("--org-id <id>", `organization id (or ${ORG_ID_ENV}); alternative to --org`).option("--db-url <url>", "PostgreSQL connection URL to monitor").option("--tag <tag>", "Docker image tag to use (e.g., 0.14.0, 0.14.0-dev.33)").option("--project <name>", "Docker Compose project name (default: postgres_ai)").option("--instance-id <uuid>", "adopt a console-provisioned monitoring instance instead of self-registering a new one (set automatically by the provisioning flow; PGAI_INSTANCE_ID env also works)").option("--vcpus <n>", "source DB vCPU count used for AAS zone thresholds (PGAI_VCPUS env also works). Omit or 0 = unknown: the platform keeps the value it stamped at provision time. An explicit non-zero value overwrites the stored one; changing it re-arms the AAS backfill.").option("--instance-jobs", "enable the instance-jobs compose profile (outbound collection channel; PGAI_INSTANCE_JOBS env also works). Writes COMPOSE_PROFILES into the stack .env, so every later mon command covers the container").option("--no-instance-jobs", "disable the instance-jobs compose profile and remove its container").option("-y, --yes", "accept all defaults and skip interactive prompts", false).action(async (opts) => {
40863
41408
  const globalOpts = program2.opts();
40864
41409
  let apiKey = opts.apiKey || globalOpts.apiKey;
40865
41410
  console.log(`
@@ -40872,18 +41417,23 @@ mon.command("local-install").description("install local monitoring stack (genera
40872
41417
  const { projectDir, instancesFile: instancesPath } = await resolveOrInitPaths();
40873
41418
  console.log(`Project directory: ${projectDir}
40874
41419
  `);
40875
- if (opts.project) {
40876
- const cfgPath = path7.resolve(projectDir, ".pgwatch-config");
40877
- updatePgwatchConfig(cfgPath, { project_name: opts.project });
40878
- console.log(`Using project name: ${opts.project}
40879
- `);
41420
+ if (opts.project && !applyProjectName(projectDir, opts.project)) {
41421
+ process.exitCode = 1;
41422
+ return;
40880
41423
  }
40881
41424
  const envFile = path7.resolve(projectDir, ".env");
40882
41425
  const existingEnv = fs9.existsSync(envFile) ? fs9.readFileSync(envFile, "utf8") : "";
40883
41426
  const previousTagMatch = existingEnv.match(/^PGAI_TAG=(.+)$/m);
40884
41427
  const previousTag = previousTagMatch ? previousTagMatch[1].trim() : null;
40885
41428
  const imageTag = opts.tag || package_default.version;
40886
- const { content: envContent, preservedKeys } = buildLocalInstallEnv(existingEnv, imageTag);
41429
+ const instanceJobsRequest = resolveInstanceJobsRequest(opts.instanceJobs, process.env.PGAI_INSTANCE_JOBS);
41430
+ if (instanceJobsRequest.error) {
41431
+ console.error(`\u2717 ${instanceJobsRequest.error}`);
41432
+ process.exitCode = 1;
41433
+ return;
41434
+ }
41435
+ const instanceJobs = instanceJobsRequest.enabled;
41436
+ const { content: envContent, preservedKeys } = buildLocalInstallEnv(existingEnv, imageTag, opts.instanceId || process.env.PGAI_INSTANCE_ID || null, path7.resolve(projectDir, ".pgwatch-config"), instanceJobs);
40887
41437
  writeEnvFile(envFile, envContent);
40888
41438
  if (preservedKeys.length > 0) {
40889
41439
  console.log(`Preserved existing .env settings: ${preservedKeys.join(", ")}
@@ -40914,6 +41464,8 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
40914
41464
  console.error(`\u26A0 Monitoring services are already running: ${containers.join(", ")}`);
40915
41465
  console.log(`Use 'postgres-ai mon restart' to restart them
40916
41466
  `);
41467
+ if (!await applyInstanceJobsProfile(projectDir, instanceJobs))
41468
+ process.exitCode = 1;
40917
41469
  return;
40918
41470
  }
40919
41471
  if (!opts.demo) {
@@ -40923,9 +41475,10 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
40923
41475
  if (apiKey) {
40924
41476
  console.log("Using API key provided via --api-key parameter");
40925
41477
  writeConfig({ apiKey });
40926
- updatePgwatchConfig(path7.resolve(projectDir, ".pgwatch-config"), { api_key: apiKey });
40927
- console.log(`\u2713 API key saved
40928
- `);
41478
+ if (!applyApiKey(projectDir, apiKey)) {
41479
+ process.exitCode = 1;
41480
+ return;
41481
+ }
40929
41482
  } else if (opts.yes) {
40930
41483
  console.log("Auto-yes mode: no API key provided, skipping API key setup");
40931
41484
  console.error("\u26A0 Reports will be generated locally only");
@@ -40940,10 +41493,11 @@ Use demo mode without API key: postgres-ai mon local-install --demo`);
40940
41493
  const trimmedKey = inputApiKey.trim();
40941
41494
  if (trimmedKey) {
40942
41495
  writeConfig({ apiKey: trimmedKey });
40943
- updatePgwatchConfig(path7.resolve(projectDir, ".pgwatch-config"), { api_key: trimmedKey });
41496
+ if (!applyApiKey(projectDir, trimmedKey)) {
41497
+ process.exitCode = 1;
41498
+ return;
41499
+ }
40944
41500
  apiKey = trimmedKey;
40945
- console.log(`\u2713 API key saved
40946
- `);
40947
41501
  break;
40948
41502
  }
40949
41503
  console.error("\u26A0 API key cannot be empty");
@@ -41201,6 +41755,9 @@ Searched: ${demoCandidates.join(", ")}
41201
41755
  }
41202
41756
  console.log(`\u2713 Services started
41203
41757
  `);
41758
+ const instanceJobsApplied = await applyInstanceJobsProfile(projectDir, instanceJobs);
41759
+ if (!instanceJobsApplied)
41760
+ process.exitCode = 1;
41204
41761
  if (apiKey && !opts.demo) {
41205
41762
  let regOrgScope;
41206
41763
  if (isGlobalTokenValue(apiKey)) {
@@ -41224,25 +41781,11 @@ Searched: ${demoCandidates.join(", ")}
41224
41781
  const plan = planMonitoringRegistration({ project: opts.project, instanceId });
41225
41782
  const projectName = plan.projectName;
41226
41783
  if (instanceId) {
41227
- const reg = await registerMonitoringInstance(apiKey, projectName, {
41784
+ const reg = await adoptAndPersist(projectDir, apiKey, projectName, instanceId, {
41228
41785
  orgScope: regOrgScope,
41229
41786
  apiBaseUrl: globalOpts.apiBaseUrl,
41230
- debug: !!process.env.DEBUG,
41231
- instanceId
41787
+ debug: !!process.env.DEBUG
41232
41788
  });
41233
- const adoptedProject = resolveAdoptedProject(reg);
41234
- if (adoptedProject != null) {
41235
- updatePgwatchConfig(path7.resolve(projectDir, ".pgwatch-config"), {
41236
- project_name: adoptedProject
41237
- });
41238
- const verb = reg?.created ? "Registered" : "Adopted";
41239
- console.log(`\u2713 ${verb} monitoring instance (project: ${adoptedProject})
41240
- `);
41241
- } else if (reg) {
41242
- 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>`));
41243
- } else {
41244
- 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>`));
41245
- }
41246
41789
  if (reg === null) {
41247
41790
  console.error(`\u26A0 Skipping AAS auto-collection arming because instance adoption failed; it can be enabled later by re-running 'postgresai mon local-install'
41248
41791
  `);
@@ -41256,7 +41799,7 @@ Searched: ${demoCandidates.join(", ")}
41256
41799
  debug: !!process.env.DEBUG
41257
41800
  });
41258
41801
  if (aas.ok) {
41259
- console.log(`\u2713 AAS auto-collection registered
41802
+ console.log(`\u2713 ${aasSuccessMessage(aas)}
41260
41803
  `);
41261
41804
  } else {
41262
41805
  console.error(`\u26A0 AAS auto-collection not registered (${aas.reason}); it can be enabled later by re-running 'postgresai mon local-install'
@@ -41267,7 +41810,7 @@ Searched: ${demoCandidates.join(", ")}
41267
41810
  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>.");
41268
41811
  process.exitCode = 1;
41269
41812
  } else {
41270
- registerMonitoringInstance(apiKey, projectName, {
41813
+ await selfRegisterAndPersist(projectDir, apiKey, projectName, {
41271
41814
  apiBaseUrl: globalOpts.apiBaseUrl,
41272
41815
  debug: !!process.env.DEBUG,
41273
41816
  orgScope: regOrgScope
@@ -41275,9 +41818,15 @@ Searched: ${demoCandidates.join(", ")}
41275
41818
  }
41276
41819
  }
41277
41820
  console.log("=================================");
41278
- console.log(" Local install completed!");
41821
+ console.log(instanceJobsApplied ? " Local install completed!" : " Local install completed WITH ONE FAILURE");
41279
41822
  console.log(`=================================
41280
41823
  `);
41824
+ if (!instanceJobsApplied) {
41825
+ console.log(instanceJobs ? `\u2717 The instance-jobs container did not start. Everything below is running; the outbound
41826
+ ` + " collection channel is not. See the error above, then `postgres-ai mon health`.\n" : `\u2717 The instance-jobs container could not be removed. Everything below is running, and so
41827
+ ` + ` is a container that keeps polling. See the error above.
41828
+ `);
41829
+ }
41281
41830
  console.log("What's running:");
41282
41831
  if (opts.demo) {
41283
41832
  console.log(" \u2705 Demo PostgreSQL database (monitoring target)");
@@ -41336,7 +41885,8 @@ var MONITORING_CONTAINERS = [
41336
41885
  "self-postgres-exporter",
41337
41886
  "flask-pgss-api",
41338
41887
  "sources-generator",
41339
- "postgres-reports"
41888
+ "postgres-reports",
41889
+ "instance-jobs"
41340
41890
  ];
41341
41891
  var COMPOSE_PROJECT_NAME = "postgres_ai";
41342
41892
  var DOCKER_NETWORK_NAME = `${COMPOSE_PROJECT_NAME}_default`;
@@ -41389,15 +41939,37 @@ mon.command("logs [service]").option("-f, --follow", "follow logs", false).optio
41389
41939
  if (code !== 0)
41390
41940
  process.exitCode = code;
41391
41941
  });
41392
- mon.command("health").description("health check for monitoring services").option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0).action(async (opts) => {
41393
- const services = [
41942
+ function judgeServiceHealth(service, present, status, health) {
41943
+ if (!present) {
41944
+ return service.optional ? { ok: true, mark: "-", label: "not enabled" } : { ok: false, mark: "\u2717", label: service.absentLabel ?? "unreachable" };
41945
+ }
41946
+ if (status !== "running") {
41947
+ return { ok: false, mark: "\u2717", label: `unhealthy (status: ${status})` };
41948
+ }
41949
+ if (service.readHealth && health && health !== "healthy" && health !== "starting") {
41950
+ return { ok: false, mark: "\u2717", label: `unhealthy (${health})` };
41951
+ }
41952
+ return { ok: true, mark: "\u2713", label: "healthy" };
41953
+ }
41954
+ function monitoringHealthServices(instanceJobsEnabled = false) {
41955
+ return [
41394
41956
  { name: "Grafana", container: "grafana-with-datasources" },
41395
41957
  { name: "Prometheus", container: "sink-prometheus" },
41396
41958
  { name: "PGWatch (Postgres)", container: "pgwatch-postgres" },
41397
41959
  { name: "PGWatch (Prometheus)", container: "pgwatch-prometheus" },
41398
41960
  { name: "Target DB", container: "target-db" },
41399
- { name: "Sink Postgres", container: "sink-postgres" }
41961
+ { name: "Sink Postgres", container: "sink-postgres" },
41962
+ {
41963
+ name: "Instance jobs",
41964
+ container: "instance-jobs",
41965
+ optional: !instanceJobsEnabled,
41966
+ readHealth: true,
41967
+ absentLabel: "enabled but not running"
41968
+ }
41400
41969
  ];
41970
+ }
41971
+ mon.command("health").description("health check for monitoring services").option("--wait <seconds>", "wait time in seconds for services to become healthy", parseInt, 0).action(async (opts) => {
41972
+ const services = monitoringHealthServices(instanceJobsProfileEnabled(readComposeProfiles()));
41401
41973
  const waitTime = opts.wait || 0;
41402
41974
  const maxAttempts = waitTime > 0 ? Math.ceil(waitTime / 5) : 1;
41403
41975
  console.log(`Checking service health...
@@ -41413,16 +41985,12 @@ mon.command("health").description("health check for monitoring services").option
41413
41985
  for (const service of services) {
41414
41986
  try {
41415
41987
  const result = spawnSync2("docker", ["inspect", "-f", "{{.State.Status}}", service.container], { stdio: "pipe" });
41416
- const status = result.stdout.trim();
41417
- if (result.status === 0 && status === "running") {
41418
- console.log(`\u2713 ${service.name}: healthy`);
41419
- } else if (result.status === 0) {
41420
- console.log(`\u2717 ${service.name}: unhealthy (status: ${status})`);
41988
+ const present = result.status === 0;
41989
+ const health = present && service.readHealth ? (spawnSync2("docker", ["inspect", "-f", "{{if .State.Health}}{{.State.Health.Status}}{{end}}", service.container], { stdio: "pipe" }).stdout || "").trim() : "";
41990
+ const verdict = judgeServiceHealth(service, present, (result.stdout || "").trim(), health);
41991
+ console.log(`${verdict.mark} ${service.name}: ${verdict.label}`);
41992
+ if (!verdict.ok)
41421
41993
  allHealthy = false;
41422
- } else {
41423
- console.log(`\u2717 ${service.name}: unreachable`);
41424
- allHealthy = false;
41425
- }
41426
41994
  } catch (error) {
41427
41995
  console.log(`\u2717 ${service.name}: unreachable`);
41428
41996
  allHealthy = false;
@@ -41535,6 +42103,12 @@ Updating Docker images...`);
41535
42103
  process.exitCode = 1;
41536
42104
  return;
41537
42105
  }
42106
+ if (instanceJobsProfileEnabled(readComposeProfiles(projectDir))) {
42107
+ if (!await applyInstanceJobsProfile(projectDir, true)) {
42108
+ process.exitCode = 1;
42109
+ return;
42110
+ }
42111
+ }
41538
42112
  console.log(`
41539
42113
  \u2713 Update completed successfully`);
41540
42114
  console.log(`
@@ -42236,8 +42810,87 @@ program2.command("orgs").description("list organizations this credential can rea
42236
42810
  process.exitCode = 1;
42237
42811
  }
42238
42812
  });
42813
+ withOrgOptions(program2.command("promql")).description("run a PromQL query on a monitoring instance's own metric store").argument("<expr>", "PromQL expression").option("--instance <uuid>", "monitoring instance id (default: this box's .pgwatch-config)").option("--at <rfc3339>", "evaluate an instant query at this time (default: now; ignored with --range)").option("--range", "run a range query (requires --start, --end, --step)").option("--start <rfc3339>", "range start").option("--end <rfc3339>", "range end").option("--step <duration>", "range step, e.g. 60s or 5m").option("--timeout <duration>", "how long to wait for an answer (default: sized from the instance's poll pacing; 11m if the platform does not report it)").option("--json", "output the raw result payload").action(async (expr, opts) => {
42814
+ try {
42815
+ const rootOpts = program2.opts();
42816
+ const cfg = readConfig();
42817
+ const { apiKey } = getConfig(rootOpts);
42818
+ if (!apiKey) {
42819
+ console.error("API key is required. Run 'pgai auth' first or set --api-key.");
42820
+ process.exitCode = 1;
42821
+ return;
42822
+ }
42823
+ const { apiBaseUrl } = resolveBaseUrls2(rootOpts, cfg);
42824
+ const instanceId = opts.instance || readInstanceIdFromProject();
42825
+ if (!instanceId) {
42826
+ console.error("No monitoring instance. Pass --instance <uuid>, or run this on a box whose");
42827
+ console.error(".pgwatch-config has an instance_id (written by 'pgai mon local-install').");
42828
+ process.exitCode = 1;
42829
+ return;
42830
+ }
42831
+ const kind = opts.range ? "promql_range" : "promql_instant";
42832
+ const enqueue = { instanceId, kind, query: expr };
42833
+ if (opts.range) {
42834
+ if (!opts.start || !opts.end || !opts.step) {
42835
+ console.error("--range needs --start, --end and --step.");
42836
+ process.exitCode = 1;
42837
+ return;
42838
+ }
42839
+ const stepS = parseDurationSeconds(opts.step);
42840
+ if (stepS === null || stepS < 1) {
42841
+ console.error(`Invalid --step ${opts.step}: expected a duration like 60s, 5m or 1h.`);
42842
+ process.exitCode = 1;
42843
+ return;
42844
+ }
42845
+ enqueue.start = opts.start;
42846
+ enqueue.end = opts.end;
42847
+ enqueue.stepS = stepS;
42848
+ } else if (opts.at) {
42849
+ enqueue.at = opts.at;
42850
+ }
42851
+ const explicitTimeoutS = opts.timeout ? parseDurationSeconds(opts.timeout) : null;
42852
+ if (opts.timeout && (explicitTimeoutS === null || explicitTimeoutS < 1)) {
42853
+ console.error(`Invalid --timeout ${opts.timeout}: expected a duration like 60s or 2m.`);
42854
+ process.exitCode = 1;
42855
+ return;
42856
+ }
42857
+ const { jobId, firstAnswerEstimateS } = await enqueueQuery(apiKey, enqueue, { apiBaseUrl });
42858
+ const timeoutS = explicitTimeoutS ?? (firstAnswerEstimateS === null ? DEFAULT_PROMQL_TIMEOUT_S : Math.min(Math.max(Math.ceil(firstAnswerEstimateS * 1.5) + 30, 60), 1800));
42859
+ console.error(firstAnswerEstimateS === null ? `Queued as job ${jobId}; waiting up to ${formatSeconds(timeoutS)} for the instance's next poll.` : `Queued as job ${jobId}; this instance polls roughly every ${formatSeconds(firstAnswerEstimateS)} ` + `(jittered, so sometimes longer). Waiting up to ${formatSeconds(timeoutS)}.`);
42860
+ const res = await awaitQueryResult(apiKey, jobId, {
42861
+ apiBaseUrl,
42862
+ timeoutMs: timeoutS * 1000
42863
+ });
42864
+ if (!isTerminal2(res.status)) {
42865
+ console.error(`Timed out after ${timeoutS}s. Job ${jobId} is still ${res.status}.`);
42866
+ console.error(res.status === "queued" ? "Nothing has claimed it: check the instance-jobs container is running on that instance " + "(`postgresai mon health`; it is behind a compose profile, enabled with " + "`mon local-install --instance-jobs`), or wait \u2014 a first collection can queue " + "~20 periods ahead of it." : "The instance is working on it \u2014 wait, or re-run with a longer --timeout.");
42867
+ process.exitCode = 1;
42868
+ return;
42869
+ }
42870
+ if (res.status !== "done" || res.outcome !== "ok") {
42871
+ console.error(`Query ${res.status}${res.failure_class ? ` (${res.failure_class})` : ""}.`);
42872
+ if (res.error)
42873
+ console.error(res.error);
42874
+ process.exitCode = 1;
42875
+ return;
42876
+ }
42877
+ if (!res.result) {
42878
+ console.error("The job finished but carried no result.");
42879
+ process.exitCode = 1;
42880
+ return;
42881
+ }
42882
+ if (opts.json) {
42883
+ console.log(JSON.stringify(res.result, null, 2));
42884
+ return;
42885
+ }
42886
+ console.log(renderPromQL(res.result));
42887
+ } catch (err) {
42888
+ console.error(err instanceof Error ? err.message : String(err));
42889
+ process.exitCode = 1;
42890
+ }
42891
+ });
42239
42892
  var issues = program2.command("issues").description("issues management");
42240
- withOrgOptions(issues.command("list")).description("list issues").option("--status <status>", "filter by status: open, closed, or all (default: all)").option("--limit <n>", "max number of issues to return (default: 20)", parseInt).option("--offset <n>", "number of issues to skip (default: 0)", parseInt).option("--hidden-only", "list only hidden issues (PostgresAI staff)").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
42893
+ withOrgOptions(issues.command("list")).description("list issues").option("--status <status>", "filter by status: open (default), closed, or all").option("--limit <n>", "max number of issues to return (default: 20)", parseInt).option("--offset <n>", "number of issues to skip (default: 0)", parseInt).option("--hidden-only", "list only hidden issues (PostgresAI staff)").option("--debug", "enable debug output").option("--json", "output raw JSON").action(async (opts) => {
42241
42894
  const spinner = createTtySpinner(process.stdout.isTTY ?? false, "Fetching issues...");
42242
42895
  try {
42243
42896
  const rootOpts = program2.opts();
@@ -42252,11 +42905,15 @@ withOrgOptions(issues.command("list")).description("list issues").option("--stat
42252
42905
  const orgScope = getActiveOrgScope2() ?? { source: "token" };
42253
42906
  const orgId = configOrgIdForBody(apiKey, cfg.orgId);
42254
42907
  const { apiBaseUrl } = resolveBaseUrls2(rootOpts, cfg);
42908
+ const statusArg = (opts.status ?? "open").trim().toLowerCase();
42255
42909
  let statusFilter;
42256
- if (opts.status === "open") {
42257
- statusFilter = "open";
42258
- } else if (opts.status === "closed") {
42259
- statusFilter = "closed";
42910
+ if (statusArg === "open" || statusArg === "closed") {
42911
+ statusFilter = statusArg;
42912
+ } else if (statusArg !== "all") {
42913
+ spinner.stop();
42914
+ console.error("--status must be open, closed, or all");
42915
+ process.exitCode = 1;
42916
+ return;
42260
42917
  }
42261
42918
  const result = await fetchIssues2({
42262
42919
  apiKey,
@@ -42270,7 +42927,7 @@ withOrgOptions(issues.command("list")).description("list issues").option("--stat
42270
42927
  debug: !!opts.debug
42271
42928
  });
42272
42929
  spinner.stop();
42273
- const trimmed = Array.isArray(result) ? result.map((r) => withVisibleHiddenFlag2({
42930
+ const trimmed = Array.isArray(result) ? result.map((r) => presentIssue2({
42274
42931
  id: r.id,
42275
42932
  title: r.title,
42276
42933
  status: r.status,
@@ -42308,7 +42965,7 @@ withOrgOptions(issues.command("view <issueId>")).description("view issue details
42308
42965
  spinner.update("Fetching comments...");
42309
42966
  const comments = await fetchIssueComments2({ apiKey, apiBaseUrl, issueId, debug: !!opts.debug });
42310
42967
  spinner.stop();
42311
- const combined = { issue: withVisibleHiddenFlag2(issue), comments };
42968
+ const combined = { issue: presentIssue2(issue), comments };
42312
42969
  printResult(combined, opts.json);
42313
42970
  } catch (err) {
42314
42971
  spinner.stop();
@@ -43512,14 +44169,29 @@ if (__require.main == __require.module) {
43512
44169
  export {
43513
44170
  LOCAL_INSTALL_MANAGED_ENV_KEYS,
43514
44171
  ORG_SCOPED_COMMANDS,
44172
+ adoptAndPersist,
44173
+ applyApiKey,
44174
+ applyProjectName,
43515
44175
  buildLocalInstallEnv,
44176
+ composeProfilesValue,
44177
+ instanceIdToPersist,
44178
+ instanceJobsProfileEnabled,
44179
+ instanceJobsRemovalOutcome,
44180
+ instanceJobsStartSkipReason,
43516
44181
  isValidComposeYaml,
44182
+ judgeServiceHealth,
44183
+ monitoringHealthServices,
44184
+ persistInstanceId,
43517
44185
  planMonitoringRegistration,
43518
44186
  program2 as program,
44187
+ readComposeProfiles,
43519
44188
  readDeployedTag,
43520
44189
  refreshBundledComposeIfStale,
43521
44190
  registerMonitoringInstance,
43522
44191
  resolveAdoptedProject,
44192
+ resolveInstanceJobsRequest,
44193
+ selfRegisterAndPersist,
43523
44194
  shouldScaleOutNodeExporter,
44195
+ statOwner,
43524
44196
  updatePgwatchConfig
43525
44197
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "postgresai",
3
- "version": "0.17.0-rc.2",
3
+ "version": "0.17.0-rc.4",
4
4
  "description": "postgres_ai CLI",
5
5
  "license": "Apache-2.0",
6
6
  "private": false,