@jacebenson/jsn 4.2.4 → 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.
@@ -0,0 +1,357 @@
1
+ /**
2
+ * Flow publishing and health checks.
3
+ *
4
+ * A flow is two things: design-time records (sys_hub_flow and its instance
5
+ * tables) and a compiled runtime snapshot. Writing design-time records alone
6
+ * produces a flow that renders correctly in the Flow Designer UI and never
7
+ * executes -- publishing is what compiles the snapshot, expands actions into
8
+ * steps, and registers the trigger in sys_flow_record_trigger.
9
+ *
10
+ * Publishing goes through the platform's Workflow Automation Fluent API. When
11
+ * that endpoint is absent (older instances, or the ServiceNow IDE plugins are
12
+ * not active) we fall back to calling FlowDesignerUtils directly through a
13
+ * background script, which is what the endpoint does internally anyway.
14
+ */
15
+
16
+ import { decodeGzipJson } from './sdk.js';
17
+
18
+ const ACTIVATE_PATH = '/api/now/wfa_fluent/activate_flows';
19
+
20
+ // The endpoint returns 200 (all published), 207 (partial) or 422 (all failed).
21
+ // 422 is NOT an error case -- it carries a per-flow result body that is the
22
+ // most useful output this command produces, so it must not be thrown away.
23
+ const RESULT_STATUSES = new Set([200, 207, 422]);
24
+
25
+ // Instances without the endpoint answer 400 with this in the message.
26
+ const MISSING_ENDPOINT = 'does not represent any resource';
27
+
28
+ /**
29
+ * Publish (activate) one or more flows.
30
+ *
31
+ * @param {object} sdk SDKClient
32
+ * @param {string[]} flowIDs sys_ids of flows/subflows to publish
33
+ * @param {string[]} actionIDs sys_ids of custom actions to publish
34
+ * @returns {Promise<{summary: object, results: object[], via: string}>}
35
+ */
36
+ export async function publishFlows(sdk, flowIDs = [], actionIDs = []) {
37
+ if (flowIDs.length === 0 && actionIDs.length === 0) {
38
+ throw new Error('Nothing to publish: provide at least one flow or action sys_id.');
39
+ }
40
+
41
+ const body = JSON.stringify({
42
+ flows: flowIDs.map(sys_id => ({ sys_id, active: 'true', state: '' })),
43
+ actions: actionIDs.map(sys_id => ({ sys_id, active: 'true', state: '' })),
44
+ });
45
+
46
+ // fetchResponse rather than request(): request() throws on any non-2xx, which
47
+ // would discard the 422 body we specifically want to report.
48
+ const resp = await sdk.fetchResponse(`${sdk.baseURL}${ACTIVATE_PATH}`, {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
51
+ body,
52
+ });
53
+
54
+ const text = await resp.text();
55
+ let parsed = null;
56
+ try { parsed = text ? JSON.parse(text) : null; } catch { /* non-JSON error page */ }
57
+
58
+ if (RESULT_STATUSES.has(resp.status) && parsed?.result) {
59
+ const { summary, results } = parsed.result;
60
+ if (summary && typeof summary === 'object' && Array.isArray(results)) {
61
+ return { summary, results, via: 'wfa_fluent' };
62
+ }
63
+ throw new Error(`Publish failed (HTTP ${resp.status}): malformed result body`);
64
+ }
65
+
66
+ const message = parsed?.result?.error?.message
67
+ ?? parsed?.error?.message
68
+ ?? parsed?.message
69
+ ?? resp.statusText;
70
+
71
+ if (message && message.includes(MISSING_ENDPOINT)) {
72
+ return publishViaScript(sdk, flowIDs, actionIDs);
73
+ }
74
+
75
+ throw new Error(`Publish failed (HTTP ${resp.status}): ${message}`);
76
+ }
77
+
78
+ /**
79
+ * Fallback: call FlowDesignerUtils.publishAll() through a background script.
80
+ * Same code path the REST endpoint uses internally.
81
+ */
82
+ export async function publishViaScript(sdk, flowIDs = [], actionIDs = []) {
83
+ const script = `
84
+ var helper = new FlowDesignerUtils();
85
+ var result = helper.publishAll({
86
+ flows: ${JSON.stringify(flowIDs.map(sys_id => ({ sys_id, active: 'true', state: '' })))},
87
+ actions: ${JSON.stringify(actionIDs.map(sys_id => ({ sys_id, active: 'true', state: '' })))}
88
+ });
89
+ gs.print('JSN_PUBLISH_RESULT:' + JSON.stringify(result));
90
+ `.trim();
91
+
92
+ const out = await sdk.executeScript(script, 'global');
93
+ const text = typeof out === 'string' ? out : (out?.output ?? '');
94
+ const match = text.match(/JSN_PUBLISH_RESULT:(\{.*\})/);
95
+ if (!match) {
96
+ throw new Error(
97
+ 'Publish fallback ran but returned no parseable result.\n'
98
+ + 'The wfa_fluent endpoint is missing on this instance -- check that the '
99
+ + 'ServiceNow IDE Platform and IDE Runtime Services plugins are active.',
100
+ );
101
+ }
102
+
103
+ let result;
104
+ try {
105
+ result = JSON.parse(match[1]);
106
+ } catch {
107
+ throw new Error('Publish fallback returned malformed JSON result.');
108
+ }
109
+ const items = result?.items;
110
+ const succeeded = result?.successCount;
111
+ if (!result || typeof result !== 'object' || Array.isArray(result)
112
+ || !Array.isArray(items)
113
+ || !Number.isInteger(succeeded) || succeeded < 0 || succeeded > items.length) {
114
+ throw new Error('Publish fallback returned a malformed result body.');
115
+ }
116
+ return {
117
+ summary: { total: items.length, succeeded, failed: items.length - succeeded },
118
+ results: items,
119
+ via: 'FlowDesignerUtils',
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Preflight: can this instance publish flows at all?
125
+ *
126
+ * The Now SDK swallows a missing endpoint at debug level, so an install can
127
+ * report success while every flow it deployed stays inert. This surfaces it.
128
+ */
129
+ export async function publishDoctor(sdk) {
130
+ const checks = [];
131
+
132
+ const defs = await sdk.list('sys_ws_definition', qs({
133
+ sysparm_query: 'service_id=wfa_fluent',
134
+ sysparm_fields: 'sys_id,name,base_uri,active',
135
+ sysparm_limit: '5',
136
+ }));
137
+ const def = defs.find(candidate => str(candidate, 'active') === 'true'
138
+ && str(candidate, 'base_uri') === '/api/now/wfa_fluent');
139
+ const defUsable = Boolean(def)
140
+ && str(def, 'active') === 'true'
141
+ && str(def, 'base_uri') === '/api/now/wfa_fluent';
142
+ checks.push({
143
+ name: 'wfa_fluent REST API',
144
+ ok: defUsable,
145
+ detail: def ? `${str(def, 'name')} at ${str(def, 'base_uri')}` : 'not found',
146
+ });
147
+
148
+ const defID = str(def, 'sys_id');
149
+ const ops = await sdk.list('sys_ws_operation', qs({
150
+ sysparm_query: defID
151
+ ? `web_service_definition=${defID}^relative_path=/activate_flows`
152
+ : 'relative_path=/activate_flows',
153
+ sysparm_fields: 'sys_id,name,http_method,relative_path,active,web_service_definition',
154
+ sysparm_limit: '5',
155
+ }));
156
+ const op = ops.find(candidate => str(candidate, 'active') === 'true'
157
+ && str(candidate, 'http_method').toUpperCase() === 'POST'
158
+ && str(candidate, 'relative_path') === '/activate_flows'
159
+ && (!str(candidate, 'web_service_definition') || str(candidate, 'web_service_definition') === str(def, 'sys_id')));
160
+ const opUsable = Boolean(op)
161
+ && str(op, 'active') === 'true'
162
+ && str(op, 'http_method').toUpperCase() === 'POST'
163
+ && str(op, 'relative_path') === '/activate_flows';
164
+ checks.push({
165
+ name: 'POST /activate_flows operation',
166
+ ok: opUsable,
167
+ detail: op ? `${str(op, 'http_method')} ${str(op, 'relative_path')}` : 'not found',
168
+ });
169
+
170
+ // sys_plugins is commonly blocked by an API-level ACL even for admins, so
171
+ // treat this as supporting evidence: report it when readable, skip it when
172
+ // not. The endpoint checks above are the load-bearing ones -- if they pass,
173
+ // publishing works regardless of what we can see here.
174
+ let plugins = null;
175
+ try {
176
+ plugins = await sdk.list('sys_plugins', qs({
177
+ sysparm_query: 'nameSTARTSWITHServiceNow IDE',
178
+ sysparm_fields: 'name,active',
179
+ sysparm_limit: '20',
180
+ }));
181
+ } catch {
182
+ checks.push({
183
+ name: 'ServiceNow IDE plugins',
184
+ ok: true,
185
+ skipped: true,
186
+ detail: 'not checked — no read access to sys_plugins',
187
+ });
188
+ }
189
+
190
+ if (plugins) {
191
+ for (const wanted of ['ServiceNow IDE Platform', 'ServiceNow IDE Runtime Services']) {
192
+ const p = plugins.find(x => str(x, 'name') === wanted);
193
+ checks.push({
194
+ name: wanted,
195
+ ok: Boolean(p) && str(p, 'active') === 'true',
196
+ detail: p ? (str(p, 'active') === 'true' ? 'active' : 'inactive') : 'not found',
197
+ });
198
+ }
199
+ }
200
+
201
+ return { ok: checks.every(c => c.ok), checks };
202
+ }
203
+
204
+ /**
205
+ * Is this flow actually going to run?
206
+ *
207
+ * Every check here corresponds to a way a flow can look completely healthy in
208
+ * the UI and still never execute.
209
+ */
210
+ export async function flowStatus(sdk, sysID) {
211
+ const flow = await sdk.get('sys_hub_flow', sysID);
212
+ if (!flow) throw new Error(`No flow found with sys_id ${sysID}`);
213
+
214
+ const name = str(flow, 'name');
215
+ const type = str(flow, 'type');
216
+ const checks = [];
217
+
218
+ const snapshot = str(flow, 'master_snapshot');
219
+ checks.push({
220
+ name: 'Published',
221
+ ok: Boolean(snapshot),
222
+ detail: snapshot ? `snapshot ${snapshot}` : 'never published -- the flow is inert',
223
+ });
224
+
225
+ // version selects which generation of instance tables the publisher reads.
226
+ // At 1 it looks in the (empty) v1 tables and publish fails with the very
227
+ // misleading "No Trigger instance found in the flow definition".
228
+ const version = str(flow, 'version');
229
+ checks.push({
230
+ name: 'Engine version',
231
+ ok: version === '2',
232
+ detail: version === '2' ? 'version 2' : `version ${version || 'unset'} -- publish will fail misleadingly; expected 2`,
233
+ });
234
+
235
+ // The flow record is the liveness gate, NOT the trigger registration row --
236
+ // deactivating a flow leaves sys_flow_record_trigger.active = 1 behind.
237
+ const active = str(flow, 'active') === 'true';
238
+ checks.push({
239
+ name: 'Active',
240
+ ok: active,
241
+ detail: active ? 'active' : 'inactive -- will not run',
242
+ });
243
+
244
+ if (type === 'subflow') {
245
+ checks.push({ name: 'Trigger', ok: true, detail: 'subflow -- invoked, no trigger expected' });
246
+ return { flow: { sysID, name, type }, ok: checks.every(c => c.ok), checks };
247
+ }
248
+
249
+ const trig = await triggerInfo(sdk, sysID);
250
+ if (!trig) {
251
+ checks.push({ name: 'Trigger', ok: false, detail: 'no trigger instance found — a flow needs exactly one' });
252
+ return { flow: { sysID, name, type }, ok: false, checks };
253
+ }
254
+
255
+ // Only record triggers register a row in sys_flow_record_trigger. Scheduled
256
+ // and application triggers (service_catalog, inbound_email, sla_task,
257
+ // knowledge_management, ...) are dispatched elsewhere, so the registration
258
+ // and condition checks below simply do not apply to them.
259
+ if (!trig.type.startsWith('record_')) {
260
+ checks.push({
261
+ name: 'Trigger',
262
+ ok: true,
263
+ detail: `${trig.type} — not a record trigger, no sys_flow_record_trigger registration expected`,
264
+ });
265
+ return { flow: { sysID, name, type, triggerType: trig.type }, ok: checks.every(c => c.ok), checks };
266
+ }
267
+
268
+ const table = trig.table;
269
+ if (!table) {
270
+ checks.push({ name: 'Trigger', ok: false, detail: `${trig.type} trigger has no table set` });
271
+ return { flow: { sysID, name, type, triggerType: trig.type }, ok: false, checks };
272
+ }
273
+
274
+ const regs = await sdk.list('sys_flow_record_trigger', qs({
275
+ sysparm_query: `table=${table}`,
276
+ sysparm_fields: 'sys_id,table,condition,active,on_insert,on_update,on_delete',
277
+ sysparm_limit: '100',
278
+ }));
279
+
280
+ // NOTE: sys_flow_record_trigger has no column linking a row back to its
281
+ // flow. Keep the table-level evidence useful, but never report it as proof
282
+ // that this particular flow is registered.
283
+ const scope = `${table} (table-wide — registrations cannot be attributed to a single flow)`;
284
+
285
+ checks.push({
286
+ name: 'Table registrations',
287
+ ok: true,
288
+ skipped: true,
289
+ detail: regs.length
290
+ ? `${regs.length} registration(s) on ${scope}`
291
+ : `no registration on ${table}`,
292
+ });
293
+ checks.push({
294
+ name: 'Trigger registered',
295
+ ok: true,
296
+ skipped: true,
297
+ detail: regs.length
298
+ ? `cannot verify registration for this flow: ${regs.length} registration(s) found on ${scope}`
299
+ : `cannot verify registration for this flow: no registration on ${table}`,
300
+ });
301
+
302
+ // An empty/null condition is never matched -- it does not mean "always".
303
+ // Flow Designer writes "^EQ" for "no condition"; tools that write "" produce
304
+ // a flow that reports published + active and silently never fires.
305
+ const nullCondition = regs.filter(r => !str(r, 'condition'));
306
+ if (regs.length) {
307
+ checks.push({
308
+ name: 'Trigger condition',
309
+ ok: true,
310
+ skipped: true,
311
+ detail: nullCondition.length === 0
312
+ ? `all ${regs.length} registration(s) on ${table} have a condition set`
313
+ : `${nullCondition.length} of ${regs.length} registration(s) on ${scope} have an empty condition — those never fire (expected "^EQ" for "no condition")`,
314
+ });
315
+ }
316
+
317
+ return { flow: { sysID, name, type, table }, ok: checks.every(c => c.ok), checks };
318
+ }
319
+
320
+ /**
321
+ * The flow's trigger type, and its table when it is a record trigger.
322
+ * Returns null when the flow has no trigger instance at all.
323
+ */
324
+ async function triggerInfo(sdk, flowSysID) {
325
+ for (const tbl of ['sys_hub_trigger_instance_v2', 'sys_hub_trigger_instance']) {
326
+ let rows;
327
+ try {
328
+ rows = await sdk.list(tbl, qs({
329
+ sysparm_query: `flow=${flowSysID}`,
330
+ sysparm_fields: 'sys_id,trigger_type,trigger_inputs',
331
+ sysparm_limit: '5',
332
+ }));
333
+ } catch { continue; } // table may not exist on this release
334
+ for (const row of rows) {
335
+ const type = str(row, 'trigger_type');
336
+ if (!type) continue;
337
+ // trigger_inputs is gzip+base64 JSON: the full denormalized input list
338
+ // for the trigger type, of which we only want the "table" entry.
339
+ const inputs = decodeGzipJson(row?.trigger_inputs);
340
+ const t = Array.isArray(inputs) ? inputs.find(i => i?.name === 'table') : null;
341
+ return { type, table: t?.value ? String(t.value) : null };
342
+ }
343
+ }
344
+ return null;
345
+ }
346
+
347
+ function qs(obj) {
348
+ const p = new URLSearchParams();
349
+ for (const [k, v] of Object.entries(obj)) p.set(k, v);
350
+ return p;
351
+ }
352
+
353
+ function str(record, field) {
354
+ const v = record?.[field];
355
+ if (v && typeof v === 'object') return String(v.value ?? v.display_value ?? '');
356
+ return v == null ? '' : String(v);
357
+ }
package/src/helpers.js CHANGED
@@ -224,7 +224,7 @@ export function resolveFieldsParam(columns) {
224
224
  // silently broadens an exact-match lookup — with sysparm_limit=1 the
225
225
  // wrong record wins (issue #143 finding #5). Exact-match identifiers
226
226
  // (numbers, sys_ids, names) never legitimately contain them.
227
- const QUERY_METACHARS = /[\^=<>~!,]/;
227
+ const QUERY_METACHARS = /[\\^=<>~!,]/;
228
228
 
229
229
  /**
230
230
  * Validate that an identifier is safe to use in an exact-match encoded
@@ -233,6 +233,12 @@ const QUERY_METACHARS = /[\^=<>~!,]/;
233
233
  * @param {string} value — identifier (number, sys_id, name)
234
234
  */
235
235
  export function assertSafeExactMatch(value) {
236
+ if (typeof value === 'string' && value.includes('*')) {
237
+ throw new Error(
238
+ 'Unsafe identifier for exact-match lookup: contains wildcard (*). ' +
239
+ 'Refusing to build a query that could match more than the named record.'
240
+ );
241
+ }
236
242
  if (value && QUERY_METACHARS.test(value)) {
237
243
  throw new Error(
238
244
  `Unsafe identifier for exact-match lookup: contains ServiceNow query characters (^ = < > ~ ! ,). ` +
@@ -0,0 +1,112 @@
1
+ import { parseNodeStatsXml } from './commands/platform.js';
2
+
3
+ function valueOf(value) {
4
+ if (value && typeof value === 'object') return value.value ?? value.display_value;
5
+ return value;
6
+ }
7
+
8
+ function numeric(value) {
9
+ if (value == null || String(value).trim() === '') return null;
10
+ const n = Number(String(value).replaceAll(',', ''));
11
+ return Number.isFinite(n) ? n : null;
12
+ }
13
+
14
+ async function platform(sdk) {
15
+ const rows = await sdk.list('sys_cluster_state', { sysparm_fields: 'sys_id,status,node_type,node_stats', sysparm_limit: '100' });
16
+ const nodes = [];
17
+ for (const row of rows) {
18
+ const id = valueOf(row.node_stats);
19
+ if (!id) continue;
20
+ const stats = await sdk.get('sys_cluster_node_stats', id);
21
+ nodes.push({
22
+ sys_id: valueOf(row.sys_id),
23
+ status: valueOf(row.status),
24
+ node_type: valueOf(row.node_type),
25
+ stats: parseNodeStatsXml(stats?.stats || ''),
26
+ });
27
+ }
28
+ return { metrics: { node_count: nodes.length, nodes }, security: { raw_xml: 'excluded', log_content: 'excluded' } };
29
+ }
30
+
31
+ async function transactions(sdk) {
32
+ const query = 'sys_created_on>=javascript:gs.daysAgoStart(1)';
33
+ const result = await sdk.aggregate('syslog_transaction', { query, groupBy: ['type'], count: true, averageFields: ['response_time'], minimumFields: ['response_time'], maximumFields: ['response_time'] });
34
+ const types = (result?.groups || []).map(group => {
35
+ const type = group.groupby_fields?.find(f => f.field === 'type')?.value || 'unknown';
36
+ const stats = group.stats || {};
37
+ const metric = (name, field) => numeric(typeof stats[name] === 'object' ? stats[name]?.[field] : stats[name]);
38
+ return { type, count: numeric(stats.count) ?? 0, avg_response_time_ms: metric('avg', 'response_time'), min_response_time_ms: metric('min', 'response_time'), max_response_time_ms: metric('max', 'response_time') };
39
+ });
40
+ return { query, metrics: { transaction_types: types } };
41
+ }
42
+
43
+ async function logSummary(sdk) {
44
+ const result = await sdk.aggregate('syslog', { query: 'sys_created_on>=javascript:gs.daysAgoStart(1)', groupBy: ['source', 'level'], count: true });
45
+ const groups = (result?.groups || []).map(group => ({
46
+ source: group.groupby_fields?.find(f => f.field === 'source')?.value || '',
47
+ severity: group.groupby_fields?.find(f => f.field === 'level')?.value || '',
48
+ count: numeric(group.stats?.count) ?? 0,
49
+ }));
50
+ return { query: 'sys_created_on>=javascript:gs.daysAgoStart(1)', metrics: { log_groups: groups }, security: { content: 'excluded' } };
51
+ }
52
+
53
+ async function countTables(sdk, tables) {
54
+ const counts = {};
55
+ for (const table of tables) counts[table] = await sdk.aggregateCount(table, 'sys_created_on>=javascript:gs.daysAgoStart(1)');
56
+ return { metrics: { counts } };
57
+ }
58
+
59
+ async function flows(sdk) {
60
+ const query = 'sys_created_on>=javascript:gs.daysAgoStart(1)';
61
+ const result = await sdk.aggregate('sys_flow_context', { query, groupBy: ['state'], count: true, averageFields: ['duration'] });
62
+ const states = (result?.groups || []).map(group => ({
63
+ state: group.groupby_fields?.find(f => f.field === 'state')?.value || 'unknown',
64
+ count: numeric(group.stats?.count) ?? 0,
65
+ avg_duration: numeric(group.stats?.avg?.duration),
66
+ }));
67
+ return { query, metrics: { flow_states: states } };
68
+ }
69
+
70
+ async function eventQueue(sdk) {
71
+ const result = await sdk.aggregate('sysevent', { query: 'stateINready,processing,error^sys_created_on>=javascript:gs.daysAgoStart(1)', groupBy: ['state'], count: true });
72
+ const states = (result?.groups || []).map(group => ({
73
+ state: group.groupby_fields?.find(f => f.field === 'state')?.value || 'unknown',
74
+ count: numeric(group.stats?.count),
75
+ }));
76
+ return { query: 'stateINready,processing,error^sys_created_on>=javascript:gs.daysAgoStart(1)', metrics: { event_queue: states } };
77
+ }
78
+
79
+ const COLLECTORS = [
80
+ ['platform_health', platform],
81
+ ['transactions', transactions],
82
+ ['error_warning_summary', logSummary],
83
+ ['event_queue', eventQueue],
84
+ ['ecc_queue', (sdk) => countTables(sdk, ['ecc_queue'])],
85
+ ['flow_executions', flows],
86
+ ['record_counts', (sdk) => countTables(sdk, ['incident', 'change_request', 'problem'])],
87
+ ['scheduled_and_cleanup', (sdk) => countTables(sdk, ['sys_trigger', 'sysauto_script'])],
88
+ ];
89
+
90
+ function statusForError(err) {
91
+ const message = String(err?.message || err);
92
+ if (/timed out|timeout/i.test(message)) return 'timeout';
93
+ if (/permission|forbidden|access denied|401|403/i.test(message)) return 'permission_denied';
94
+ if (/not found|does not exist|invalid table|404/i.test(message)) return 'unsupported';
95
+ if (/network|fetch|connect/i.test(message)) return 'unavailable';
96
+ return 'query_failure';
97
+ }
98
+
99
+ async function runCollector(name, fn, sdk) {
100
+ const start = new Date().toISOString();
101
+ try {
102
+ const data = await fn(sdk);
103
+ return { name, status: 'success', reason: null, start_time: start, finish_time: new Date().toISOString(), data };
104
+ } catch (err) {
105
+ return { name, status: statusForError(err), reason: String(err?.message || err), start_time: start, finish_time: new Date().toISOString(), data: null };
106
+ }
107
+ }
108
+
109
+ export async function capturePerformance({ sdk }) {
110
+ const collectors = await Promise.all(COLLECTORS.map(([name, fn]) => runCollector(name, fn, sdk)));
111
+ return { status: collectors.every(c => c.status === 'success') ? 'complete' : 'incomplete', collectors };
112
+ }