@jacebenson/jsn 4.2.3 → 4.3.0

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/src/perf.js ADDED
@@ -0,0 +1,335 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import Database from 'better-sqlite3';
6
+ import { getVersion } from './commands/version.js';
7
+ import { capturePerformance } from './perf-capture.js';
8
+
9
+ export const PERF_SCHEMA_VERSION = 1;
10
+
11
+ function dataHome() {
12
+ return process.env.JSN_DATA_HOME || path.join(os.homedir(), '.jsn');
13
+ }
14
+
15
+ export function getPerfDir() {
16
+ return path.join(dataHome(), 'perf');
17
+ }
18
+
19
+ export function getPerfDbPath() {
20
+ return path.join(getPerfDir(), 'perf.db');
21
+ }
22
+
23
+ export function openPerfDb(opts = {}) {
24
+ const dir = opts.dir || getPerfDir();
25
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ const db = new Database(opts.dbPath || path.join(dir, 'perf.db'));
27
+ db.pragma('journal_mode = WAL');
28
+ db.exec(`
29
+ CREATE TABLE IF NOT EXISTS runs (
30
+ run_id TEXT PRIMARY KEY,
31
+ label TEXT,
32
+ profile TEXT,
33
+ instance TEXT NOT NULL,
34
+ username TEXT,
35
+ command_options TEXT NOT NULL,
36
+ start_time TEXT NOT NULL,
37
+ finish_time TEXT,
38
+ status TEXT NOT NULL,
39
+ jsn_version TEXT NOT NULL,
40
+ capture_schema_version INTEGER NOT NULL
41
+ );
42
+ CREATE TABLE IF NOT EXISTS collectors (
43
+ run_id TEXT NOT NULL REFERENCES runs(run_id) ON DELETE CASCADE,
44
+ name TEXT NOT NULL,
45
+ status TEXT NOT NULL,
46
+ reason TEXT,
47
+ start_time TEXT NOT NULL,
48
+ finish_time TEXT,
49
+ data TEXT,
50
+ PRIMARY KEY (run_id, name)
51
+ );
52
+ CREATE INDEX IF NOT EXISTS idx_perf_runs_instance ON runs(instance, profile, start_time);
53
+ `);
54
+ return db;
55
+ }
56
+
57
+ export function closePerfDb(db) {
58
+ try { db.close(); } catch { /* already closed */ }
59
+ }
60
+
61
+ function nextRunId(db, now = new Date()) {
62
+ const base = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
63
+ let id = base;
64
+ let n = 1;
65
+ while (db.prepare('SELECT 1 FROM runs WHERE run_id = ?').get(id)) id = `${base}-${n++}`;
66
+ return id;
67
+ }
68
+
69
+ function lockKey(profile, instance) {
70
+ return crypto.createHash('sha256').update(`${profile || ''}\0${instance}`).digest('hex').slice(0, 24);
71
+ }
72
+
73
+ export function acquireCaptureLock(profile, instance, opts = {}) {
74
+ const dir = opts.dir || getPerfDir();
75
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
76
+ const file = path.join(dir, `capture-${lockKey(profile, instance)}.lock`);
77
+ try {
78
+ const fd = fs.openSync(file, 'wx', 0o600);
79
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, profile: profile || '', instance, started_at: new Date().toISOString() }));
80
+ return { file, release: () => {
81
+ try { fs.closeSync(fd); } catch { /* already closed */ }
82
+ try { fs.unlinkSync(file); } catch { /* already removed */ }
83
+ } };
84
+ } catch (err) {
85
+ if (err.code === 'EEXIST') {
86
+ const e = new Error(`A performance capture is already running for profile "${profile || '(default)'}" and instance ${instance}`);
87
+ e.code = 'perf_capture_overlap';
88
+ e.hint = 'Wait for the existing capture to finish, or remove the lock only after confirming that process is gone.';
89
+ throw e;
90
+ }
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ export async function captureRun({ sdk, instance, profile = '', username = '', label = '', options = {} }) {
96
+ if (!sdk || !instance) throw new Error('An authenticated instance is required for perf capture');
97
+ const lock = acquireCaptureLock(profile, instance);
98
+ let db;
99
+ try {
100
+ db = openPerfDb();
101
+ const start = new Date().toISOString();
102
+ const runId = nextRunId(db);
103
+ const capture = await capturePerformance({ sdk, instance, profile, username, label, options });
104
+ const { collectors, status } = capture;
105
+ const finish = new Date().toISOString();
106
+ db.prepare(`INSERT INTO runs (run_id,label,profile,instance,username,command_options,start_time,finish_time,status,jsn_version,capture_schema_version) VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
107
+ .run(runId, label || null, profile || null, instance, username || null, JSON.stringify(options), start, finish, status, getVersion(), PERF_SCHEMA_VERSION);
108
+ const insert = db.prepare('INSERT INTO collectors (run_id,name,status,reason,start_time,finish_time,data) VALUES (?,?,?,?,?,?,?)');
109
+ const save = db.transaction(items => { for (const c of items) insert.run(runId, c.name, c.status, c.reason, c.start_time, c.finish_time, c.data == null ? null : JSON.stringify(c.data)); });
110
+ save(collectors);
111
+ return getRun(runId, db);
112
+ } finally {
113
+ if (db) closePerfDb(db);
114
+ lock.release();
115
+ }
116
+ }
117
+
118
+ function parseRun(row, collectors) {
119
+ return {
120
+ run_id: row.run_id, label: row.label, profile: row.profile, instance: row.instance, username: row.username,
121
+ command_options: JSON.parse(row.command_options || '{}'), start_time: row.start_time, finish_time: row.finish_time,
122
+ status: row.status, jsn_version: row.jsn_version, capture_schema_version: row.capture_schema_version,
123
+ collectors: collectors.map(c => ({ ...c, data: c.data ? JSON.parse(c.data) : null })),
124
+ };
125
+ }
126
+
127
+ export function getRun(runId, db = null) {
128
+ const ownsDb = !db;
129
+ const database = db || openPerfDb();
130
+ try {
131
+ const row = database.prepare('SELECT * FROM runs WHERE run_id = ?').get(runId);
132
+ if (!row) return null;
133
+ const collectors = database.prepare('SELECT name,status,reason,start_time,finish_time,data FROM collectors WHERE run_id = ? ORDER BY name').all(runId);
134
+ return parseRun(row, collectors);
135
+ } finally {
136
+ if (ownsDb) closePerfDb(database);
137
+ }
138
+ }
139
+
140
+ export function listRuns({ profile, instance, limit = 50 } = {}) {
141
+ const db = openPerfDb();
142
+ try {
143
+ let sql = 'SELECT * FROM runs WHERE 1=1';
144
+ const args = [];
145
+ if (profile) { sql += ' AND profile = ?'; args.push(profile); }
146
+ if (instance) { sql += ' AND instance = ?'; args.push(instance); }
147
+ sql += ' ORDER BY start_time DESC LIMIT ?'; args.push(Math.max(1, Math.min(1000, Number(limit) || 50)));
148
+ return db.prepare(sql).all(...args).map(row => ({ run_id: row.run_id, label: row.label, profile: row.profile, instance: row.instance, start_time: row.start_time, finish_time: row.finish_time, status: row.status, jsn_version: row.jsn_version, capture_schema_version: row.capture_schema_version }));
149
+ } finally { closePerfDb(db); }
150
+ }
151
+
152
+ function arrayIdentity(item, index) {
153
+ if (!item || typeof item !== 'object') return String(index);
154
+ if (item.source != null || item.severity != null) return `source=${item.source ?? 'unknown'},severity=${item.severity ?? 'unknown'}`;
155
+ for (const field of ['type', 'state', 'name', 'sys_id']) if (item[field] != null) return `${field}=${item[field]}`;
156
+ return String(index);
157
+ }
158
+
159
+ function flatten(value, prefix = '', out = new Map()) {
160
+ if (value == null) return out;
161
+ if (typeof value === 'number' && Number.isFinite(value)) out.set(prefix, value);
162
+ else if (Array.isArray(value)) value.forEach((v, i) => flatten(v, `${prefix}[${arrayIdentity(v, i)}]`, out));
163
+ else if (typeof value === 'object') Object.entries(value).forEach(([k, v]) => flatten(v, prefix ? `${prefix}.${k}` : k, out));
164
+ return out;
165
+ }
166
+
167
+ function runContext(run) {
168
+ return {
169
+ run_id: run.run_id,
170
+ label: run.label,
171
+ instance: run.instance,
172
+ profile: run.profile,
173
+ start_time: run.start_time,
174
+ finish_time: run.finish_time,
175
+ };
176
+ }
177
+
178
+ export function compareRuns(baseline, newer) {
179
+ const a = new Map();
180
+ const b = new Map();
181
+ for (const c of baseline.collectors) flatten(c.data?.metrics, c.name, a);
182
+ for (const c of newer.collectors) flatten(c.data?.metrics, c.name, b);
183
+ const names = [...new Set([...a.keys(), ...b.keys()])].sort();
184
+ const metrics = names.map(metric => {
185
+ if (!a.has(metric)) return { metric, availability: 'missing_from_baseline', baseline: null, new: b.get(metric), delta: null, percent_change: null };
186
+ if (!b.has(metric)) return { metric, availability: 'missing_from_new', baseline: a.get(metric), new: null, delta: null, percent_change: null };
187
+ const delta = b.get(metric) - a.get(metric);
188
+ return { metric, availability: 'available', baseline: a.get(metric), new: b.get(metric), delta, percent_change: a.get(metric) === 0 ? null : (delta / a.get(metric)) * 100 };
189
+ });
190
+ return { baseline: runContext(baseline), new: runContext(newer), baseline_run_id: baseline.run_id, new_run_id: newer.run_id, status: metrics.some(m => m.availability !== 'available') ? 'incomplete' : 'complete', metrics };
191
+ }
192
+
193
+ export function formatRun(run) {
194
+ const lines = [`Run ${run.run_id}${run.label ? ` (${run.label})` : ''}`, `Status: ${run.status}`, `Instance: ${run.instance}`, `Started: ${run.start_time}`, 'COLLECTOR STATUS REASON'];
195
+ for (const c of run.collectors) lines.push(`${c.name.padEnd(25)} ${c.status.padEnd(18)} ${c.reason || ''}`);
196
+ return `${lines.join('\n')}\n`;
197
+ }
198
+
199
+ export function formatRunDetailed(run) {
200
+ const lines = [`Run ${run.run_id}${run.label ? ` (${run.label})` : ''}`, `Status: ${run.status}`, `Instance: ${run.instance}`, `Profile: ${run.profile || 'default'}`, `Started: ${run.start_time}`, 'COLLECTOR STATUS REASON'];
201
+ for (const c of run.collectors) lines.push(`${c.name.padEnd(25)} ${c.status.padEnd(18)} ${c.reason || ''}`);
202
+ lines.push('', 'CAPTURED DETAILS');
203
+ for (const c of run.collectors) {
204
+ lines.push(` ${c.name}`);
205
+ if (c.status !== 'success' || !c.data) {
206
+ lines.push(` ${c.reason || 'No data captured'}`);
207
+ continue;
208
+ }
209
+ const metrics = c.data.metrics || {};
210
+ if (c.name === 'transactions') {
211
+ for (const row of metrics.transaction_types || []) lines.push(` ${row.type}: ${row.count ?? 'unavailable'} requests, avg ${row.avg_response_time_ms ?? 'unavailable'} ms, max ${row.max_response_time_ms ?? 'unavailable'} ms`);
212
+ } else if (c.name === 'platform_health') {
213
+ lines.push(` Nodes: ${metrics.node_count ?? 'unavailable'}`);
214
+ for (const node of metrics.nodes || []) lines.push(` ${node.status || '?'} node ${node.sys_id || ''}: queue ${node.stats?.queue?.length ?? 'unavailable'}, sessions ${node.stats?.active_sessions ?? 'unavailable'}`);
215
+ } else if (c.name === 'error_warning_summary') {
216
+ const totals = {};
217
+ for (const row of metrics.log_groups || []) totals[row.severity || 'unknown'] = (totals[row.severity || 'unknown'] || 0) + (row.count || 0);
218
+ lines.push(` Groups: ${(metrics.log_groups || []).length}, by severity: ${Object.entries(totals).map(([severity, count]) => `${severity}=${count}`).join(', ') || 'none'}`);
219
+ } else if (c.name === 'flow_executions') {
220
+ for (const row of metrics.flow_states || []) lines.push(` ${row.state}: ${row.count ?? 'unavailable'} executions, avg ${row.avg_duration ?? 'unavailable'} ms`);
221
+ } else if (c.name === 'event_queue') {
222
+ lines.push(` States: ${(metrics.event_queue || []).length}`);
223
+ for (const row of metrics.event_queue || []) lines.push(` ${row.groupby_fields?.[0]?.value || 'unknown'}: ${row.stats?.count ?? 'unavailable'}`);
224
+ } else if (metrics.counts) {
225
+ for (const [name, value] of Object.entries(metrics.counts)) lines.push(` ${name}: ${value ?? 'unavailable'}`);
226
+ } else {
227
+ lines.push(` ${JSON.stringify(metrics)}`);
228
+ }
229
+ }
230
+ const newline = String.fromCharCode(10);
231
+ return lines.join(newline) + newline;
232
+ }
233
+
234
+ export function formatRunList(runs) {
235
+ const lines = ['RUN ID STATUS LABEL INSTANCE'];
236
+ lines.push('------------------------- ---------- -------------------- ------------------------------');
237
+ for (const run of runs) lines.push(`${run.run_id.padEnd(25)} ${run.status.padEnd(10)} ${String(run.label || '').slice(0, 20).padEnd(20)} ${run.instance}`);
238
+ if (runs.length === 0) lines.push('(no performance captures)');
239
+ return `${lines.join('\n')}\n`;
240
+ }
241
+
242
+ export function formatComparisonDetailed(result) {
243
+ const shortMetric = (metric) => metric
244
+ .replace(/error_warning_summary\.log_groups\[source=([^,\]]+),severity=([^\]]+)\]/g, 'logs[$1,sev=$2]')
245
+ .replace(/transactions\.transaction_types\[type=([^\]]+)\]/g, 'tx[$1]')
246
+ .replace('error_warning_summary', 'logs')
247
+ .replace('platform_health', 'platform')
248
+ .replace('flow_executions', 'flows')
249
+ .replace('scheduled_and_cleanup', 'scheduled')
250
+ .replace('record_counts', 'records')
251
+ .replace(/nodes\[sys_id=([^\]]+)\]/g, (_, id) => `node[${id.slice(0, 8)}]`)
252
+ .replace(/semaphores\[name=([^\]]+)\]/g, 'sem[$1]')
253
+ .replace('.stats.active_sessions', '.sessions')
254
+ .replace('.stats.queue.length', '.queue')
255
+ .replace('.stats.queue.age', '.queue_age')
256
+ .replace('.stats.semaphores', '.semaphores')
257
+ .replace('.stats.transactions.daily', '.daily_tx')
258
+ .replace('.avg_response_time_ms', '.avg_ms')
259
+ .replace('.min_response_time_ms', '.min_ms')
260
+ .replace('.max_response_time_ms', '.max_ms')
261
+ .replace('.stats.', '.');
262
+ const displayMetric = metric => shortMetric(metric);
263
+ const displayValue = value => typeof value === 'number' && !Number.isInteger(value) ? value.toFixed(2) : String(value);
264
+ const semaphoreFields = ['available', 'borrowed', 'maximum_concurrency', 'queue_depth', 'queue_depth_limit'];
265
+ const semaphoreLabels = ['avail', 'borrow', 'max', 'qd', 'qlim'];
266
+ const semaphorePattern = /^(.*\.semaphores\[name=[^\]]+\])\.(available|borrowed|maximum_concurrency|queue_depth|queue_depth_limit)$/;
267
+ const grouped = new Map();
268
+ const regular = [];
269
+ for (const metric of result.metrics) {
270
+ const match = metric.metric.match(semaphorePattern);
271
+ if (!match) {
272
+ regular.push(metric);
273
+ continue;
274
+ }
275
+ if (!grouped.has(match[1])) grouped.set(match[1], new Map());
276
+ grouped.get(match[1]).set(match[2], metric);
277
+ }
278
+ const formatValue = value => value == null ? '?' : displayValue(value);
279
+ const formatSemaphore = (name, fields, width) => {
280
+ const values = side => semaphoreFields.map(field => formatValue(fields.get(field)?.[side])).join(' ');
281
+ const delta = semaphoreFields.map(field => formatValue(fields.get(field)?.delta)).join(' ');
282
+ const label = shortMetric(name).replace(/^platform\./, '');
283
+ return `${label.padEnd(width)} ${values('baseline').padStart(16)} | ${values('new').padStart(16)} | ${delta.padStart(16)}`;
284
+ };
285
+ const sectionFor = metric => {
286
+ if (metric.startsWith('ecc_queue.')) return 'ECC queue';
287
+ if (metric.startsWith('logs[')) return 'Logs';
288
+ if (metric.startsWith('flows.')) return 'Flows';
289
+ if (metric.startsWith('platform.')) return 'Platform';
290
+ if (metric.startsWith('records.')) return 'Records';
291
+ if (metric.startsWith('scheduled.')) return 'Scheduled';
292
+ if (metric.startsWith('tx[')) return 'Transactions';
293
+ return 'Other';
294
+ };
295
+ const lines = [
296
+ `Performance comparison: ${result.status}`,
297
+ `Baseline: ${result.baseline?.profile || 'default'} @ ${result.baseline?.instance || 'unknown'} (${result.baseline_run_id})`,
298
+ `New: ${result.new?.profile || 'default'} @ ${result.new?.instance || 'unknown'} (${result.new_run_id})`,
299
+ ];
300
+ const addMetric = (m, width) => {
301
+ const metric = displayMetric(m.metric);
302
+ if (m.availability === 'missing_from_baseline') lines.push(`${metric.padEnd(width)} Unavailable: missing from baseline`);
303
+ else if (m.availability === 'missing_from_new') lines.push(`${metric.padEnd(width)} Unavailable: missing from new result`);
304
+ else lines.push(`${metric.padEnd(width)} ${displayValue(m.baseline).padStart(12)} ${displayValue(m.new).padStart(12)} ${displayValue(m.delta).padStart(12)}`);
305
+ };
306
+ const sections = new Map();
307
+ for (const metric of regular) {
308
+ const section = sectionFor(displayMetric(metric.metric));
309
+ if (!sections.has(section)) sections.set(section, []);
310
+ sections.get(section).push(metric);
311
+ }
312
+ for (const [section, metrics] of sections) {
313
+ const width = Math.max(34, ...metrics.map(metric => displayMetric(metric.metric).length));
314
+ lines.push('', section.toUpperCase(), `${'METRIC'.padEnd(width)} BASELINE NEW DELTA`);
315
+ for (const metric of metrics) addMetric(metric, width);
316
+ }
317
+ if (grouped.size) {
318
+ const semaphoreNames = [...grouped.keys()].map(name => shortMetric(name).replace(/^platform\./, ''));
319
+ const width = Math.max(36, ...semaphoreNames.map(name => name.length));
320
+ lines.push('', 'SEMAPHORES', `${'NAME'.padEnd(width)} BASELINE [${semaphoreLabels.join(' ')}] | NEW [${semaphoreLabels.join(' ')}] | DELTA [${semaphoreLabels.join(' ')}]`);
321
+ for (const [name, fields] of grouped) lines.push(formatSemaphore(name, fields, width));
322
+ }
323
+ const newline = String.fromCharCode(10);
324
+ return lines.join(newline) + newline;
325
+ }
326
+
327
+ export function formatComparison(result) {
328
+ const lines = [`Compare ${result.baseline_run_id} -> ${result.new_run_id}`, `Overall: ${result.status}`, 'METRIC BASELINE NEW DELTA'];
329
+ for (const m of result.metrics) {
330
+ if (m.availability === 'missing_from_baseline') lines.push(`${m.metric.padEnd(42)} Unavailable: missing from baseline`);
331
+ else if (m.availability === 'missing_from_new') lines.push(`${m.metric.padEnd(42)} Unavailable: missing from new result`);
332
+ else lines.push(`${m.metric.padEnd(42)} ${String(m.baseline).padStart(12)} ${String(m.new).padStart(12)} ${String(m.delta).padStart(12)}`);
333
+ }
334
+ return `${lines.join('\n')}\n`;
335
+ }
package/src/sdk.js CHANGED
@@ -47,19 +47,17 @@ export class SDKClient {
47
47
  case 'oauth':
48
48
  req.headers.set('Authorization', `Bearer ${creds.access_token}`);
49
49
  break;
50
+ case 'gck':
51
+ req.headers.set('X-UserToken', creds.access_token);
52
+ if (creds.cookies) req.headers.set('Cookie', creds.cookies);
53
+ break;
50
54
  default:
51
- if (creds.username && creds.password) {
52
- req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
53
- } else if (creds.access_token) {
54
- req.headers.set('Authorization', `Bearer ${creds.access_token}`);
55
- } else {
56
- throw errAuth('No valid credentials');
57
- }
55
+ throw errAuth('Unsupported authentication method');
58
56
  }
59
57
  }
60
58
 
61
59
  async request(endpoint, opts = {}) {
62
- const { timeout = this.timeout, ...requestOptions } = opts;
60
+ const { timeout = this.timeout, touchLastSeen = true, ...requestOptions } = opts;
63
61
  const controller = new AbortController();
64
62
  const timer = setTimeout(() => controller.abort(), timeout);
65
63
 
@@ -90,7 +88,7 @@ export class SDKClient {
90
88
 
91
89
  // Stamp last_seen on successful API call
92
90
  try {
93
- if (this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
91
+ if (touchLastSeen && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
94
92
  this.authProvider.touchLastSeen(this.baseURL);
95
93
  }
96
94
  } catch { /* non-fatal */ }
@@ -122,8 +120,11 @@ export class SDKClient {
122
120
  }
123
121
  }
124
122
 
125
- // rawRequest is like request() but returns the response text as-is (no JSON parsing).
126
- // Used for non-JSON endpoints like sys.scripts.do (returns HTML).
123
+ // Public raw-response seam for callers that must inspect non-2xx bodies.
124
+ async fetchResponse(endpoint, opts = {}) {
125
+ return this._fetchWithAuth(endpoint, opts);
126
+ }
127
+
127
128
  async rawRequest(endpoint, opts = {}) {
128
129
  const controller = new AbortController();
129
130
  const timer = setTimeout(() => controller.abort(), this.timeout);
@@ -159,10 +160,10 @@ export class SDKClient {
159
160
  }
160
161
  }
161
162
 
162
- async list(table, params = {}) {
163
+ async list(table, params = {}, opts = {}) {
163
164
  const query = new URLSearchParams(params).toString();
164
165
  const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
165
- const result = await this.request(endpoint, { method: 'GET' });
166
+ const result = await this.request(endpoint, { method: 'GET', ...opts });
166
167
  return result?.result || [];
167
168
  }
168
169
 
@@ -244,13 +245,13 @@ export class SDKClient {
244
245
  return result?.result || null;
245
246
  }
246
247
 
247
- async getCurrentUser() {
248
+ async getCurrentUser({ touchLastSeen = true } = {}) {
248
249
  const params = new URLSearchParams();
249
250
  params.set('sysparm_query', 'user_name=javascript:gs.getUserName()');
250
251
  params.set('sysparm_limit', '1');
251
252
  params.set('sysparm_display_value', 'all');
252
253
  params.set('sysparm_fields', 'sys_id,user_name,name');
253
- const records = await this.list('sys_user', params);
254
+ const records = await this.list('sys_user', params, { touchLastSeen });
254
255
  if (records.length === 0) return null;
255
256
  const r = records[0];
256
257
  return {
@@ -326,7 +327,7 @@ export class SDKClient {
326
327
  try {
327
328
  const pfResponse = await this.request(`${this.baseURL}/api/now/processflow/flow/${flowSysID}`, { method: 'GET' });
328
329
  const pfData = pfResponse?.result?.data;
329
- if (pfData && typeof pfData === 'object' && !pfResponse?.result?.errorMessage) {
330
+ if (isUsableFlowPayload(pfData) && !pfResponse?.result?.errorMessage) {
330
331
  inspection.payload = pfData;
331
332
  if (!inspection.flow.version) {
332
333
  const pfVersion = getStringField(pfData, 'version');
@@ -359,8 +360,10 @@ export class SDKClient {
359
360
  if (payload) {
360
361
  try {
361
362
  const payloadData = JSON.parse(payload);
362
- inspection.payload = payloadData;
363
- extractPayloadData(inspection, payloadData);
363
+ if (isUsableFlowPayload(payloadData)) {
364
+ inspection.payload = payloadData;
365
+ extractPayloadData(inspection, payloadData);
366
+ }
364
367
  } catch {
365
368
  // ignore parse error
366
369
  }
@@ -458,9 +461,49 @@ export class SDKClient {
458
461
  if (records) inspection.flowVariables = records;
459
462
  }
460
463
 
464
+ await this._loadFlowCatalogRecords(inspection);
461
465
  return inspection;
462
466
  }
463
467
 
468
+ async _loadFlowCatalogRecords(inspection) {
469
+ const refs = new Map();
470
+ const tokenRe = /([0-9a-f]{32}):([a-z_]+)/g;
471
+ const addInputs = inputs => {
472
+ if (!Array.isArray(inputs)) return;
473
+ for (const input of inputs) {
474
+ const value = getStringField(input, 'displayValue') || getStringField(input, 'value');
475
+ for (const [, id, table] of String(value).matchAll(tokenRe)) {
476
+ if (!refs.has(`${table}:${id}`)) refs.set(`${table}:${id}`, { id, table });
477
+ }
478
+ }
479
+ };
480
+ const walk = items => {
481
+ if (!Array.isArray(items)) return;
482
+ for (const item of items) {
483
+ addInputs(item?.inputs);
484
+ walk(item?.flowBlock);
485
+ }
486
+ };
487
+ walk(inspection.actionInstances);
488
+ walk(inspection.flowLogicInstances);
489
+ for (const logic of inspection.flowLogicInstances) addInputs(logic?._decodedValues?.inputs);
490
+ Object.defineProperty(inspection, 'catalogRecords', {
491
+ value: [],
492
+ enumerable: false,
493
+ configurable: true,
494
+ writable: true,
495
+ });
496
+ for (const { id, table } of refs.values()) {
497
+ try {
498
+ const params = new URLSearchParams({ sysparm_query: `sys_id=${id}`, sysparm_fields: 'sys_id,name,question_text', sysparm_display_value: 'all', sysparm_limit: '1' });
499
+ const record = (await this.list(table, params))[0];
500
+ if (record) inspection.catalogRecords.push({ ...record, table });
501
+ } catch {
502
+ // Catalog labels are optional enrichment.
503
+ }
504
+ }
505
+ }
506
+
464
507
  /**
465
508
  * Resolve a custom action and return its internal step instances.
466
509
  * The action reference on sys_hub_step_instance points at the shared
@@ -800,7 +843,15 @@ function getBoolField(record, field) {
800
843
  return false;
801
844
  }
802
845
 
846
+ function isUsableFlowPayload(payload) {
847
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
848
+ return ['actionInstances', 'flowLogicInstances', 'subFlowInstances']
849
+ .some(key => Array.isArray(payload[key]) && payload[key].some(item => item && typeof item === 'object' && !Array.isArray(item)));
850
+ }
851
+
803
852
  function extractPayloadData(inspection, payload) {
853
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
854
+
804
855
  const actions = toMapSlice(payload.actionInstances);
805
856
  if (actions) inspection.actionInstances = actions;
806
857
 
package/src/session.js CHANGED
@@ -25,6 +25,9 @@
25
25
  // --instance flag > --profile reference > active profile >
26
26
  // default profile > legacy bare instanceURL
27
27
  //
28
+ // When both explicit selectors are supplied, they must resolve to the same
29
+ // normalized instance; otherwise the command fails with a usage error.
30
+ //
28
31
  // Deliberately NOT here: env var loading (loadConfig already folds
29
32
  // SERVICENOW_INSTANCE_URL into cfg.instanceURL before the resolver runs)
30
33
  // and credential storage I/O (auth.js owns the keyring/file stores).
@@ -76,6 +79,17 @@ export function resolveSession(argv = {}, config) {
76
79
 
77
80
  const profile = profileName ? profiles[profileName] : null;
78
81
 
82
+ // Explicit selectors must identify the same target. Without this guard,
83
+ // --profile supplies the credentials while --instance silently points the
84
+ // SDK at another identity's instance.
85
+ if (argv.instance && argv.profile && profile?.instance_url) {
86
+ const flagInstance = normalizeInstanceURL(argv.instance);
87
+ const profileInstance = normalizeInstanceURL(profile.instance_url);
88
+ if (flagInstance !== profileInstance) {
89
+ throw errUsage(`Conflicting selectors: --profile "${argv.profile}" targets ${profileInstance}, but --instance targets ${flagInstance}.`);
90
+ }
91
+ }
92
+
79
93
  // Instance: --instance flag > profile reference > active/default
80
94
  // profile > legacy bare instanceURL. The override is normalized here so
81
95
  // every downstream consumer (SDK client, context header links) sees the