@jacebenson/jsn 4.2.2 → 4.2.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.
@@ -0,0 +1,184 @@
1
+ import { getStringField } from './helpers.js';
2
+
3
+ export const FLOW_CONTEXT_CANDIDATE_FIELDS = [
4
+ 'name', 'flow', 'flow_catalog_model', 'execution_id', 'state', 'status',
5
+ 'started', 'start_time', 'started_at', 'ended', 'end_time', 'ended_at', 'completed_on',
6
+ 'sys_created_on', 'sys_updated_on', 'duration', 'run_time',
7
+ 'execution_duration', 'execution_state', 'wait_for', 'waiting_for', 'error', 'error_message',
8
+ 'exception', 'message', 'source_record', 'engine_major_version', 'origins',
9
+ 'calling_source',
10
+ ];
11
+
12
+ function findValue(record, names) {
13
+ for (const name of names) {
14
+ const result = getStringField(record, name).trim();
15
+ if (result) return { value: result, field: name };
16
+ }
17
+ return { value: '', field: null };
18
+ }
19
+
20
+ function dateValue(input) {
21
+ if (!input) return null;
22
+ const date = new Date(String(input).replace(' ', 'T') + (String(input).includes('Z') ? '' : 'Z'));
23
+ return Number.isNaN(date.getTime()) ? null : date;
24
+ }
25
+
26
+ function secondsFromDuration(input) {
27
+ if (!input) return null;
28
+ if (/^\d+(\.\d+)?$/.test(input)) return Number(input);
29
+ const parts = input.split(':').map(Number);
30
+ if (parts.some(Number.isNaN) || parts.length > 3) return null;
31
+ if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
32
+ if (parts.length === 2) return parts[0] * 60 + parts[1];
33
+ return null;
34
+ }
35
+
36
+ function isWaiting(status) {
37
+ return /wait|queue|pause|pending/i.test(status);
38
+ }
39
+
40
+ function isRunning(status) {
41
+ return /running|executing|processing/i.test(status);
42
+ }
43
+
44
+ export function normalizeFlowContext(record, { now = new Date() } = {}) {
45
+ const status = findValue(record, ['state', 'status', 'execution_state']);
46
+ const runtimeStarted = findValue(record, ['started', 'start_time', 'started_at']);
47
+ const started = runtimeStarted.value ? runtimeStarted : findValue(record, ['sys_created_on']);
48
+ const ended = findValue(record, ['ended', 'end_time', 'ended_at', 'completed_on']);
49
+ const duration = findValue(record, ['duration', 'run_time', 'execution_duration']);
50
+ const waitFor = findValue(record, ['wait_for', 'waiting_for']);
51
+ const error = findValue(record, ['error', 'error_message', 'exception', 'message']);
52
+ const startedDate = dateValue(started.value);
53
+ const runtimeStartedDate = dateValue(runtimeStarted.value);
54
+ const endedDate = dateValue(ended.value);
55
+ const explicitDuration = secondsFromDuration(duration.value);
56
+ const durationSeconds = explicitDuration ?? (runtimeStartedDate && endedDate ? Math.max(0, (endedDate - runtimeStartedDate) / 1000) : null);
57
+ const ageSeconds = startedDate ? Math.max(0, (new Date(now) - startedDate) / 1000) : null;
58
+ const runtimeAgeSeconds = runtimeStartedDate ? Math.max(0, (new Date(now) - runtimeStartedDate) / 1000) : null;
59
+
60
+ return {
61
+ sys_id: findValue(record, ['sys_id']).value,
62
+ flow: findValue(record, ['name', 'flow', 'flow_catalog_model']).value || '(deleted flow)',
63
+ execution_id: findValue(record, ['execution_id']).value,
64
+ status: status.value,
65
+ started: started.value || null,
66
+ ended: ended.value || null,
67
+ duration_seconds: durationSeconds == null ? null : Math.round(durationSeconds),
68
+ waiting_age_seconds: isWaiting(status.value) && ageSeconds != null ? Math.round(ageSeconds) : null,
69
+ execution_age_seconds: isRunning(status.value) && runtimeAgeSeconds != null ? Math.round(runtimeAgeSeconds) : null,
70
+ wait_for: waitFor.value || null,
71
+ error: error.value || null,
72
+ version: findValue(record, ['engine_major_version']).value || null,
73
+ source_record: findValue(record, ['source_record']).value || null,
74
+ field_mapping: {
75
+ status: status.field,
76
+ started: started.field,
77
+ ended: ended.field,
78
+ duration: duration.field || (durationSeconds != null ? 'derived' : null),
79
+ wait_for: waitFor.field,
80
+ error: error.field,
81
+ },
82
+ missing_fields: ['started', 'ended', 'duration'].filter(field => {
83
+ const mapping = { started: started.field, ended: ended.field, duration: duration.field || (durationSeconds != null ? 'derived' : null) };
84
+ return !mapping[field];
85
+ }),
86
+ };
87
+ }
88
+
89
+ export function formatFlowContextSummary(summary) {
90
+ const lines = ['', '▶ FLOW EXECUTIONS', '─'.repeat(72), ` Total matching: ${summary.total_count ?? '(unavailable)'}`, ` Sampled: ${summary.sample_count}`, ''];
91
+ for (const [flow, metrics] of Object.entries(summary.by_flow).sort(([a], [b]) => a.localeCompare(b))) {
92
+ const statuses = Object.entries(metrics.statuses).map(([status, count]) => `${status}: ${count}`).join(', ');
93
+ const duration = metrics.duration_seconds.count > 0
94
+ ? `${metrics.duration_seconds.min}s-${metrics.duration_seconds.max}s, avg ${metrics.duration_seconds.average}s`
95
+ : '(no completed durations)';
96
+ lines.push(` ${flow} (${metrics.count}${metrics.sampled ? `, ${metrics.sampled} sampled` : ''})`);
97
+ lines.push(` Status: ${statuses}`);
98
+ lines.push(` Duration sample: ${duration}`);
99
+ if (metrics.waiting_count > 0) lines.push(` Waiting sample: ${metrics.waiting_count}`);
100
+ }
101
+ return lines.join('\n') + '\n';
102
+ }
103
+
104
+ export function aggregateFlowContextMappings(executions) {
105
+ const keys = ['status', 'started', 'ended', 'duration', 'wait_for', 'error'];
106
+ return Object.fromEntries(keys.map(key => {
107
+ const values = [...new Set(executions.map(execution => execution.field_mapping?.[key]).filter(Boolean))];
108
+ return [key, values.length === 1 ? values[0] : values.length > 1 ? 'mixed' : null];
109
+ }));
110
+ }
111
+
112
+ export function mergeFlowContextStats(groups, sampledSummary, totalCount) {
113
+ const byFlow = {};
114
+ for (const group of groups || []) {
115
+ const fields = Object.fromEntries((group.groupby_fields || []).map(field => [field.field, field.value]));
116
+ const flow = fields.name || '(deleted flow)';
117
+ const status = fields.state || fields.status || '(unknown)';
118
+ const bucket = byFlow[flow] ||= { count: 0, sampled: 0, statuses: {}, waiting_count: 0, duration_seconds: { count: 0, min: null, max: null, average: null } };
119
+ const count = Number(group.stats?.count ?? 0);
120
+ bucket.count += Number.isFinite(count) ? count : 0;
121
+ bucket.statuses[status] = (bucket.statuses[status] || 0) + (Number.isFinite(count) ? count : 0);
122
+ }
123
+ for (const [flow, sample] of Object.entries(sampledSummary.by_flow)) {
124
+ const bucket = byFlow[flow] ||= { count: 0, sampled: 0, statuses: {}, waiting_count: 0, duration_seconds: { count: 0, min: null, max: null, average: null } };
125
+ bucket.sampled = sample.count;
126
+ if (bucket.count === 0) bucket.count = sample.count;
127
+ bucket.waiting_count = sample.waiting_count;
128
+ bucket.duration_seconds = sample.duration_seconds;
129
+ }
130
+ return { total_count: totalCount, sample_count: sampledSummary.count, by_flow: byFlow };
131
+ }
132
+
133
+ export function buildFlowContextQuery({ record, since, until, query } = {}) {
134
+ const parts = [];
135
+ for (const [label, value, operator] of [['record', record, '='], ['since', since, '>='], ['until', until, '<=']]) {
136
+ if (value == null || value === '') continue;
137
+ if (/[\\^<>=]/.test(value)) throw new Error(`invalid --${label}: use a timestamp or sys_id without query operators`);
138
+ parts.push(`${label === 'record' ? 'source_record' : 'sys_created_on'}${operator}${value}`);
139
+ }
140
+ if (query) parts.push(query);
141
+ parts.push('ORDERBYDESCsys_created_on');
142
+ return parts.join('^');
143
+ }
144
+
145
+ export function summarizeFlowContexts(executions) {
146
+ const byFlow = {};
147
+ for (const execution of executions) {
148
+ const flow = execution.flow || '(deleted flow)';
149
+ const bucket = byFlow[flow] ||= { count: 0, statuses: {}, waiting_count: 0, duration_seconds: { count: 0, min: null, max: null, average: null, _sum: 0 } };
150
+ bucket.count += 1;
151
+ const status = execution.status || '(unknown)';
152
+ bucket.statuses[status] = (bucket.statuses[status] || 0) + 1;
153
+ if (execution.waiting_age_seconds != null) bucket.waiting_count += 1;
154
+ if (execution.duration_seconds != null) {
155
+ const stats = bucket.duration_seconds;
156
+ stats.count += 1;
157
+ stats.min = stats.min == null ? execution.duration_seconds : Math.min(stats.min, execution.duration_seconds);
158
+ stats.max = stats.max == null ? execution.duration_seconds : Math.max(stats.max, execution.duration_seconds);
159
+ stats._sum += execution.duration_seconds;
160
+ stats.average = Math.round(stats._sum / stats.count);
161
+ }
162
+ }
163
+ for (const bucket of Object.values(byFlow)) delete bucket.duration_seconds._sum;
164
+ return { count: executions.length, by_flow: byFlow };
165
+ }
166
+
167
+ export async function discoverFlowContextFields(sdk) {
168
+ const fallback = new Set(['sys_id', ...FLOW_CONTEXT_CANDIDATE_FIELDS]);
169
+ try {
170
+ const params = new URLSearchParams();
171
+ params.set('sysparm_query', `name=sys_flow_context^elementIN${FLOW_CONTEXT_CANDIDATE_FIELDS.join(',')}`);
172
+ params.set('sysparm_fields', 'element');
173
+ params.set('sysparm_limit', '100');
174
+ const dictionary = await sdk.list('sys_dictionary', params);
175
+ const discovered = new Set(['sys_id']);
176
+ for (const row of dictionary) {
177
+ const element = getStringField(row, 'element');
178
+ if (element) discovered.add(element);
179
+ }
180
+ return discovered.size > 1 ? discovered : fallback;
181
+ } catch {
182
+ return fallback;
183
+ }
184
+ }
@@ -1,4 +1,5 @@
1
1
  import { isHexString, getStringField, assertSafeExactMatch } from '../helpers.js';
2
+ import { discoverFlowContextFields, normalizeFlowContext } from '../flow-context.js';
2
3
 
3
4
  export async function resolveIdentifier(app, table, identifier) {
4
5
  // If it looks like a sys_id (32 hex chars), use it directly
@@ -164,20 +165,15 @@ async function fetchBusinessRules(app, table) {
164
165
  }
165
166
 
166
167
  async function fetchFlows(app, sysId) {
167
- const params = new URLSearchParams();
168
- params.set('sysparm_query', `source_record=${sysId}`);
169
- params.set('sysparm_limit', '20');
170
- params.set('sysparm_fields', 'flow_catalog_model,name,execution_id,state,engine_major_version,sys_created_on,origins,calling_source');
171
- params.set('sysparm_display_value', 'all');
172
168
  try {
169
+ const fields = await discoverFlowContextFields(app.sdk);
170
+ const params = new URLSearchParams();
171
+ params.set('sysparm_query', `source_record=${sysId}^ORDERBYDESCsys_created_on`);
172
+ params.set('sysparm_limit', '20');
173
+ params.set('sysparm_fields', [...fields].join(','));
174
+ params.set('sysparm_display_value', 'all');
173
175
  const records = await app.sdk.list('sys_flow_context', params);
174
- return records.map(r => ({
175
- flow: r.name?.display_value || r.name || r.flow_catalog_model?.display_value || r.flow_catalog_model?.value || '(deleted flow)',
176
- executionId: r.execution_id?.display_value || r.execution_id,
177
- state: r.state?.display_value || r.state,
178
- version: r.engine_major_version?.display_value || r.engine_major_version,
179
- started: r.sys_created_on?.display_value || r.sys_created_on,
180
- }));
176
+ return records.map(r => normalizeFlowContext(r));
181
177
  } catch {
182
178
  return []; // Flow Designer might not be installed
183
179
  }
@@ -245,8 +241,13 @@ export function formatInspectOutput(data) {
245
241
  } else {
246
242
  for (const f of data.flows) {
247
243
  lines.push(` Flow: ${f.flow}`);
248
- lines.push(` Status: ${f.state} | Version: ${f.version}`);
244
+ lines.push(` Status: ${f.status || '(unknown)'}${f.execution_id ? ` | Execution: ${f.execution_id}` : ''}`);
249
245
  if (f.started) lines.push(` Started: ${f.started}`);
246
+ if (f.ended) lines.push(` Ended: ${f.ended}`);
247
+ if (f.duration_seconds != null) lines.push(` Duration: ${f.duration_seconds}s`);
248
+ if (f.waiting_age_seconds != null) lines.push(` Waiting age: ${f.waiting_age_seconds}s`);
249
+ if (f.wait_for) lines.push(` Waiting for: ${f.wait_for}`);
250
+ if (f.error) lines.push(` Error: ${f.error}`);
250
251
  lines.push('');
251
252
  }
252
253
  }
package/src/sdk.js CHANGED
@@ -47,6 +47,10 @@ 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
55
  if (creds.username && creds.password) {
52
56
  req.headers.set('Authorization', 'Basic ' + Buffer.from(`${creds.username}:${creds.password}`).toString('base64'));
@@ -59,16 +63,17 @@ export class SDKClient {
59
63
  }
60
64
 
61
65
  async request(endpoint, opts = {}) {
66
+ const { timeout = this.timeout, ...requestOptions } = opts;
62
67
  const controller = new AbortController();
63
- const timer = setTimeout(() => controller.abort(), this.timeout);
68
+ const timer = setTimeout(() => controller.abort(), timeout);
64
69
 
65
70
  try {
66
71
  const req = new Request(endpoint, {
67
- ...opts,
72
+ ...requestOptions,
68
73
  signal: controller.signal,
69
74
  });
70
75
  req.headers.set('Accept', 'application/json');
71
- if (opts.body && typeof opts.body === 'string') {
76
+ if (requestOptions.body && typeof requestOptions.body === 'string') {
72
77
  req.headers.set('Content-Type', 'application/json');
73
78
  }
74
79
  if (this.domain) {
@@ -460,12 +465,49 @@ export class SDKClient {
460
465
  return inspection;
461
466
  }
462
467
 
463
- async aggregateCount(table, queryStr) {
468
+ /**
469
+ * Resolve a custom action and return its internal step instances.
470
+ * The action reference on sys_hub_step_instance points at the shared
471
+ * Action Type Base, so the action definition sys_id is the query value.
472
+ *
473
+ * @param {string} identifier - action name or sys_id
474
+ * @returns {Promise<{action: object, steps: object[]}|null>}
475
+ */
476
+ async inspectCustomAction(identifier) {
477
+ if (!identifier) return null;
478
+ const isSysID = /^[0-9a-fA-F]{32}$/.test(String(identifier));
479
+ const actionQuery = new URLSearchParams();
480
+ actionQuery.set('sysparm_display_value', 'all');
481
+ actionQuery.set('sysparm_limit', '1');
482
+ actionQuery.set('sysparm_query', isSysID ? `sys_id=${identifier}` : `name=${identifier}`);
483
+ actionQuery.set('sysparm_fields', 'sys_id,name,master_snapshot,latest_snapshot,state,active');
484
+
485
+ const actions = await this.list('sys_hub_action_type_definition', actionQuery);
486
+ if (!actions || actions.length === 0) return null;
487
+
488
+ const action = actions[0];
489
+ const actionSysID = getStringField(action, 'sys_id');
490
+ if (!actionSysID) return null;
491
+
492
+ const stepQuery = new URLSearchParams();
493
+ stepQuery.set('sysparm_display_value', 'all');
494
+ stepQuery.set('sysparm_query', `action=${actionSysID}^ORDERBYorder`);
495
+ stepQuery.set('sysparm_fields', 'sys_id,label,action,step_type,order,section,inputs,outputs,extended_inputs,extended_outputs,error_handling_type');
496
+ stepQuery.set('sysparm_limit', '200');
497
+ const steps = await this.list('sys_hub_step_instance', stepQuery);
498
+
499
+ return { action, steps: steps || [] };
500
+ }
501
+
502
+ async aggregateCount(table, queryStr, options = {}) {
464
503
  const params = new URLSearchParams();
465
504
  params.set('sysparm_count', 'true');
466
505
  if (queryStr) params.set('sysparm_query', queryStr);
467
506
  const endpoint = `${this.baseURL}/api/now/stats/${table}?${params.toString()}`;
468
- const result = await this.request(endpoint, { method: 'GET' });
507
+ const result = await this.request(endpoint, {
508
+ method: 'GET',
509
+ ...(options.timeout ? { timeout: options.timeout } : {}),
510
+ });
469
511
  const stats = result?.result?.stats;
470
512
  if (!stats) return 0;
471
513
 
@@ -497,7 +539,31 @@ export class SDKClient {
497
539
  return 0;
498
540
  }
499
541
 
500
- // ─── Background Script Execution (3-step OAuth session flow) ───
542
+ async aggregate(table, options = {}) {
543
+ const params = new URLSearchParams();
544
+ const fields = (value) => Array.isArray(value) ? value.filter(Boolean).join(',') : String(value || '');
545
+ const groupBy = fields(options.groupBy);
546
+ const orderBy = fields(options.orderBy);
547
+
548
+ if (options.query) params.set('sysparm_query', options.query);
549
+ if (options.count !== false) params.set('sysparm_count', 'true');
550
+ if (groupBy) params.set('sysparm_group_by', groupBy);
551
+ if (fields(options.averageFields)) params.set('sysparm_avg_fields', fields(options.averageFields));
552
+ if (fields(options.sumFields)) params.set('sysparm_sum_fields', fields(options.sumFields));
553
+ if (fields(options.minimumFields)) params.set('sysparm_min_fields', fields(options.minimumFields));
554
+ if (fields(options.maximumFields)) params.set('sysparm_max_fields', fields(options.maximumFields));
555
+ if (orderBy) params.set('sysparm_order_by', orderBy);
556
+
557
+ const endpoint = `${this.baseURL}/api/now/stats/${table}?${params.toString()}`;
558
+ const result = await this.request(endpoint, { method: 'GET' });
559
+ const payload = result?.result || {};
560
+ const numericKeys = Object.keys(payload).filter(key => /^\d+$/.test(key));
561
+ if (numericKeys.length > 0 && numericKeys.length === Object.keys(payload).length) {
562
+ return { groups: numericKeys.sort((a, b) => Number(a) - Number(b)).map(key => payload[key]) };
563
+ }
564
+ return payload;
565
+ }
566
+
501
567
 
502
568
  /**
503
569
  * Export an update set to XML via the fluent export endpoint.