@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.
@@ -1,8 +1,25 @@
1
1
  import { formatRecordForDisplay, getStringField, interactiveList } from '../../helpers.js';
2
2
  import { discoverFlowContextFields, normalizeFlowContext, summarizeFlowContexts, formatFlowContextSummary, aggregateFlowContextMappings, mergeFlowContextStats, buildFlowContextQuery } from '../../flow-context.js';
3
3
  import { declareCapabilities } from '../../capabilities.js';
4
+ import { inspectFlow } from '../../flow-inspection.js';
5
+ import { publishFlows, publishDoctor, flowStatus } from '../../flow-publish.js';
6
+ import { resolveRecord, unwrapSysId } from '../../resolve-record.js';
4
7
 
5
- declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete'], devAlias: true });
8
+ function flowInspectionAdapter(app) {
9
+ return {
10
+ inspectFlow: app.sdk.inspectFlow.bind(app.sdk),
11
+ inspectCustomAction: app.sdk.inspectCustomAction.bind(app.sdk),
12
+ };
13
+ }
14
+
15
+ declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'delete', 'publish'], devAlias: true });
16
+
17
+ function renderChecks(checks) {
18
+ return checks.map((c) => {
19
+ const mark = c.skipped ? '➖' : (c.ok ? '✅' : '❌');
20
+ return ` ${mark} ${c.name}: ${c.detail}`;
21
+ }).join('\n');
22
+ }
6
23
 
7
24
  export function flowsCmd(wrap) {
8
25
  return {
@@ -33,18 +50,17 @@ export function flowsCmd(wrap) {
33
50
  const picked = await interactiveList({
34
51
  app, table: 'sys_hub_flow', singular: 'flow', columns, limit: argv.limit, query, labelField: 'name',
35
52
  formatLabel: r => `${getStringField(r, 'active') === 'true' ? '🟢' : '🔴'} ${getStringField(r, 'name')}`,
53
+ promptFn: app.promptFn,
36
54
  });
37
55
  if (picked === undefined) return; // user cancelled
38
56
  if (picked) {
39
- const inspection = await app.sdk.inspectFlow(getStringField(picked, 'sys_id'));
40
- const ctx = {
41
- sdk: app.sdk,
57
+ const inspection = await inspectFlow({
58
+ adapter: flowInspectionAdapter(app),
59
+ identifier: getStringField(picked, 'sys_id'),
42
60
  instanceURL: app.getEffectiveInstance(),
43
- depth: Math.max(1, Math.floor(argv.depth ?? 2)),
44
- visited: new Set([inspection.flow.sysID]),
45
- };
46
- const formatted = await formatFlowInspection(inspection, ctx);
47
- return app.ok({ ...inspection, _formatted: formatted }, {
61
+ depth: argv.depth,
62
+ });
63
+ return app.ok(inspection, {
48
64
  summary: `Flow: ${inspection.flow.name}`,
49
65
  breadcrumbs: [{ action: 'list', cmd: 'jsn flows list', description: 'Back to all flows' }],
50
66
  });
@@ -134,16 +150,13 @@ export function flowsCmd(wrap) {
134
150
  describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
135
151
  }),
136
152
  handler: wrap(async (argv, app) => {
137
- const inspection = await app.sdk.inspectFlow(argv.identifier);
138
- const ctx = {
139
- sdk: app.sdk,
153
+ const inspection = await inspectFlow({
154
+ adapter: flowInspectionAdapter(app),
155
+ identifier: argv.identifier,
140
156
  instanceURL: app.getEffectiveInstance(),
141
- depth: Math.max(1, Math.floor(argv.depth ?? 2)),
142
- visited: new Set([inspection.flow.sysID]),
143
- };
144
- const formatted = await formatFlowInspection(inspection, ctx);
145
- const data = { ...inspection, _formatted: formatted };
146
- app.ok(data, {
157
+ depth: argv.depth,
158
+ });
159
+ app.ok(inspection, {
147
160
  summary: `Flow: ${inspection.flow.name}`,
148
161
  breadcrumbs: [
149
162
  { action: 'list', cmd: 'jsn flows list', description: 'Back to all flows' },
@@ -151,6 +164,64 @@ export function flowsCmd(wrap) {
151
164
  });
152
165
  }),
153
166
  })
167
+ .command({
168
+ command: 'publish <identifier>',
169
+ aliases: ['activate'],
170
+ describe: 'Publish (activate) a flow so it can actually run',
171
+ builder: (y) => y
172
+ .option('action', { type: 'boolean', default: false, describe: 'Treat the identifier as a custom action rather than a flow' }),
173
+ handler: wrap(async (argv, app) => {
174
+ app.requireInstance();
175
+ const sysID = unwrapSysId(await resolveRecord(app.sdk, {
176
+ table: argv.action ? 'sys_hub_action_type_definition' : 'sys_hub_flow',
177
+ identifier: argv.identifier,
178
+ matchField: 'name',
179
+ resource: argv.action ? 'Custom action' : 'Flow',
180
+ }));
181
+
182
+ const result = argv.action
183
+ ? await publishFlows(app.sdk, [], [sysID])
184
+ : await publishFlows(app.sdk, [sysID], []);
185
+
186
+ const { summary, results, via } = result;
187
+ const lines = results.map(r => ` ${r.status === 'success' ? '✅' : '❌'} ${r.flow_name ?? r.sys_id}: ${r.message ?? r.status}${r.errorMessage ? ` — ${r.errorMessage}` : ''}`);
188
+
189
+ return app.ok({ summary, results, via }, {
190
+ summary: `Published ${summary.succeeded}/${summary.total} (via ${via})\n${lines.join('\n')}`,
191
+ breadcrumbs: [{ action: 'status', cmd: `jsn flows status ${sysID}`, description: 'Verify the flow will run' }],
192
+ });
193
+ }),
194
+ })
195
+ .command({
196
+ command: 'status <identifier>',
197
+ describe: 'Check whether a flow is actually able to run',
198
+ handler: wrap(async (argv, app) => {
199
+ app.requireInstance();
200
+ const sysID = unwrapSysId(await resolveRecord(app.sdk, {
201
+ table: 'sys_hub_flow', identifier: argv.identifier, matchField: 'name', resource: 'Flow',
202
+ }));
203
+ const status = await flowStatus(app.sdk, sysID);
204
+
205
+ return app.ok(status, {
206
+ summary: `${status.ok ? '✅' : '❌'} ${status.flow.name} (${status.flow.type})\n${renderChecks(status.checks)}`,
207
+ breadcrumbs: status.ok ? [] : [{ action: 'publish', cmd: `jsn flows publish ${sysID}`, description: 'Publish this flow' }],
208
+ });
209
+ }),
210
+ })
211
+ .command({
212
+ command: 'doctor',
213
+ describe: 'Check whether this instance can publish flows at all',
214
+ handler: wrap(async (_argv, app) => {
215
+ app.requireInstance();
216
+ const report = await publishDoctor(app.sdk);
217
+ const hint = report.ok
218
+ ? ''
219
+ : '\n\nFlow publishing is unavailable. Deploys may report success while leaving flows inert.';
220
+ return app.ok(report, {
221
+ summary: `${report.ok ? '✅' : '❌'} Flow publishing\n${renderChecks(report.checks)}${hint}`,
222
+ });
223
+ }),
224
+ })
154
225
  .command({
155
226
  command: 'create',
156
227
  describe: 'Create a new flow (not yet implemented)',
@@ -187,6 +258,9 @@ export function flowsCmd(wrap) {
187
258
  console.log(' list List flows');
188
259
  console.log(' executions Show flow executions from sys_flow_context');
189
260
  console.log(' show <identifier> Show flow details by name or sys_id');
261
+ console.log(' status <identifier> Check whether a flow is actually able to run');
262
+ console.log(' publish <identifier> Publish (activate) a flow');
263
+ console.log(' doctor Check whether this instance can publish flows');
190
264
  console.log(' create Create a new flow (not yet implemented)');
191
265
  console.log(' update <identifier> Update a flow (not yet implemented)');
192
266
  console.log(' delete <identifier> Delete a flow (not yet implemented)');
@@ -199,823 +273,3 @@ export function flowsCmd(wrap) {
199
273
  },
200
274
  };
201
275
  }
202
-
203
- export async function formatFlowInspection(inspection, ctx) {
204
- const lines = [];
205
- const instanceURL = ctx?.instanceURL || '';
206
- const flow = inspection.flow;
207
-
208
- lines.push('');
209
- lines.push(`Flow: ${flow.name}`);
210
- lines.push('');
211
-
212
- const status = flow.active ? 'Active' : 'Inactive';
213
- let version = flow.version || inferFlowVersion(inspection);
214
- lines.push(` Status: ${status} | Version: ${version}`);
215
- lines.push(` Sys ID: ${flow.sysID}`);
216
- if (instanceURL && flow.sysID) {
217
- lines.push(` Link: ${instanceURL}/sys_hub_flow.do?sys_id=${flow.sysID}`);
218
- }
219
-
220
- // Flow variables section
221
- if (inspection.flowVariables && inspection.flowVariables.length > 0) {
222
- const vars = [...inspection.flowVariables].sort((a, b) => {
223
- const ao = parseInt(getStringField(a, 'order'), 10) || 0;
224
- const bo = parseInt(getStringField(b, 'order'), 10) || 0;
225
- return ao - bo;
226
- });
227
- lines.push('');
228
- lines.push('▶ FLOW VARIABLES');
229
- lines.push('─'.repeat(50));
230
- for (const v of vars) {
231
- const name = firstNonEmpty(getStringField(v, 'label'), getStringField(v, 'name'), 'Variable');
232
- const typeName = firstNonEmpty(getStringField(v, 'type_label'), getStringField(v, 'type'), '');
233
- if (typeName) {
234
- lines.push(` • ${name}: ${typeName}`);
235
- } else {
236
- lines.push(` • ${name}`);
237
- }
238
- }
239
- }
240
-
241
- // Subflow I/O section
242
- if (flow.type && flow.type.toLowerCase() === 'subflow') {
243
- lines.push('');
244
- lines.push('▶ SUBFLOW');
245
- lines.push('─'.repeat(50));
246
-
247
- if (inspection.flowInputs && inspection.flowInputs.length > 0) {
248
- lines.push(` Inputs (${inspection.flowInputs.length})`);
249
- for (const input of inspection.flowInputs) {
250
- const name = firstNonEmpty(getStringField(input, 'label'), getStringField(input, 'name'), 'Input');
251
- const typeName = getStringField(input, 'type');
252
- if (typeName) {
253
- lines.push(` • ${name}: ${typeName}`);
254
- } else {
255
- lines.push(` • ${name}`);
256
- }
257
- }
258
- }
259
-
260
- if (inspection.flowOutputs && inspection.flowOutputs.length > 0) {
261
- if (inspection.flowInputs && inspection.flowInputs.length > 0) {
262
- lines.push('');
263
- }
264
- lines.push(` Outputs (${inspection.flowOutputs.length})`);
265
- for (const out of inspection.flowOutputs) {
266
- const name = firstNonEmpty(getStringField(out, 'label'), getStringField(out, 'name'), 'Output');
267
- const typeName = getStringField(out, 'type');
268
- if (typeName) {
269
- lines.push(` • ${name}: ${typeName}`);
270
- } else {
271
- lines.push(` • ${name}`);
272
- }
273
- }
274
- }
275
- }
276
-
277
- // Trigger section
278
- const { name: triggerName, type: triggerType, table: triggerTable, time: triggerTime, condition: triggerCondition } = extractTriggerDetails(inspection);
279
- if (triggerName || triggerType || triggerTable || triggerTime || triggerCondition) {
280
- lines.push('');
281
- lines.push('▶ TRIGGER');
282
- lines.push('─'.repeat(50));
283
- if (triggerName) lines.push(` Name: ${triggerName}`);
284
- if (triggerType) lines.push(` Type: ${titleCase(triggerType.replace(/_/g, ' '))}`);
285
- if (triggerTable) lines.push(` Table: ${triggerTable}`);
286
- if (triggerTime) lines.push(` Time: ${triggerTime}`);
287
- if (triggerCondition) lines.push(` Condition: ${formatTriggerCondition(triggerCondition)}`);
288
- }
289
-
290
- // Flow structure section
291
- lines.push('');
292
- lines.push('⚡ FLOW STRUCTURE');
293
- lines.push('─'.repeat(50));
294
- const structureLines = await formatFlowStructure(inspection, ctx);
295
- lines.push(...structureLines);
296
- // Note limitation only when we couldn't reconstruct detail from the payload OR the gzip fallback
297
- const hasPayload = inspection.payload && Object.keys(inspection.payload).length > 0;
298
- const hasDecodedLogic = (inspection.flowLogicInstances || []).some(l => l._decodedValues);
299
- if (!hasPayload && !hasDecodedLogic) {
300
- lines.push('');
301
- lines.push(' (step detail limited — no flow definition data available)');
302
- }
303
-
304
- lines.push('');
305
- return lines.join('\n') + '\n';
306
- }
307
-
308
- function extractTriggerDetails(inspection) {
309
- let name = '';
310
- let type = '';
311
- let table = '';
312
- let time = '';
313
- let condition = '';
314
-
315
- if (inspection.version && typeof inspection.version === 'object') {
316
- name = getStringField(inspection.version, 'trigger_name');
317
- type = getStringField(inspection.version, 'trigger_type');
318
- table = getStringField(inspection.version, 'trigger_table');
319
- time = getStringField(inspection.version, 'trigger_time');
320
- }
321
-
322
- if ((!name || !type || !table || !time || !condition) && Object.keys(inspection.payload).length > 0) {
323
- const triggers = inspection.payload.triggerInstances;
324
- if (Array.isArray(triggers) && triggers.length > 0) {
325
- const trigger = triggers[0];
326
- if (trigger && typeof trigger === 'object') {
327
- name = firstNonEmpty(name, getStringField(trigger, 'name'));
328
- type = firstNonEmpty(type, getStringField(trigger, 'type'));
329
- if (Array.isArray(trigger.inputs)) {
330
- for (const input of trigger.inputs) {
331
- if (!input || typeof input !== 'object') continue;
332
- const k = getStringField(input, 'name');
333
- const v = firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value'));
334
- if (k === 'table') table = firstNonEmpty(table, v);
335
- if (k === 'time') time = firstNonEmpty(time, v);
336
- if (k === 'condition') condition = firstNonEmpty(condition, v);
337
- }
338
- }
339
- }
340
- }
341
- }
342
-
343
- if (!name && inspection.triggerInstances && inspection.triggerInstances.length > 0) {
344
- const first = inspection.triggerInstances[0];
345
- name = firstNonEmpty(name, getStringField(first, 'name'), getStringField(first, 'display_text'), getNestedString(first, 'trigger_definition', 'display_value'));
346
- type = firstNonEmpty(type, getStringField(first, 'trigger_type'), getNestedString(first, 'trigger_definition', 'display_value'));
347
- }
348
-
349
- if (time.includes(' ')) {
350
- const parts = time.split(' ');
351
- if (parts.length === 2) {
352
- time = parts[1];
353
- }
354
- }
355
-
356
- return { name, type, table, time, condition };
357
- }
358
-
359
- function inferFlowVersion(inspection) {
360
- if (Object.keys(inspection.payload).length > 0) return 'Unset (Assumed V1)';
361
- if (inspection.actionInstances && inspection.actionInstances.length > 0) return 'Unset (Assumed V1)';
362
- return 'Unset';
363
- }
364
-
365
- async function formatFlowStructure(inspection, ctx) {
366
- const names = await collectCatalogVarNames(inspection, ctx);
367
- if (names.size > 0) {
368
- if (!ctx.catalogVarNames) ctx.catalogVarNames = new Map();
369
- for (const [k, v] of names) ctx.catalogVarNames.set(k, v);
370
- }
371
- const labelCache = buildLabelCache(inspection.payload);
372
- if (labelCache.size > 0) {
373
- if (!ctx.labelCache) ctx.labelCache = new Map();
374
- for (const [k, v] of labelCache) ctx.labelCache.set(k, v);
375
- }
376
- const payload = inspection.payload;
377
- if (Object.keys(payload).length > 0) {
378
- return formatFlowStructureFromPayload(payload, ctx);
379
- }
380
- return formatFlowStructureFallback(inspection);
381
- }
382
-
383
- /**
384
- * The ProcessFlow payload carries a label cache (also serialized as
385
- * labelCacheAsJsonString) mapping data-pill names like "<step-uid>.<var>"
386
- * to the human labels the Flow Designer UI shows, e.g.
387
- * "1 - Get Catalog Variables➛session_title". Build a Map so guid-prefixed
388
- * pills can be rendered readably.
389
- */
390
- function buildLabelCache(payload) {
391
- const cache = new Map();
392
- if (!payload || typeof payload !== 'object') return cache;
393
- let entries = payload.label_cache;
394
- if (!Array.isArray(entries)) {
395
- try {
396
- entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
397
- } catch {
398
- entries = [];
399
- }
400
- }
401
- for (const e of entries) {
402
- if (!e || typeof e !== 'object') continue;
403
- const name = getStringField(e, 'name');
404
- const label = getStringField(e, 'label');
405
- if (name && label) cache.set(name, label);
406
- }
407
- return cache;
408
- }
409
-
410
- const GUID_PILL_RE = /\{\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.([^}]+)\}\}/g;
411
-
412
- /**
413
- * Replace opaque guid-prefixed pills ({{<32-hex-guid>.<var>}}) with their
414
- * human label from the payload's label cache. Readable step refs like
415
- * {{Created_1.table_name}} are NOT matched and stay raw.
416
- */
417
- function resolveGuidPills(value, labelCache) {
418
- if (!labelCache || labelCache.size === 0 || !value) return value;
419
- return String(value).replace(GUID_PILL_RE, (m, guid, name) => {
420
- const label = labelCache.get(`${guid}.${name}`);
421
- return label ? `{{${label}}}` : m;
422
- });
423
- }
424
-
425
- /**
426
- * ServiceNow serializes catalog variable selections in action inputs as
427
- * comma-separated "sys_id:table" pairs (e.g. Get Catalog Variables / Create
428
- * Catalog Task steps). The payload carries the source table on the parameter
429
- * attributes, but the value itself also names it in each token. Resolve those
430
- * sys_ids to readable labels (question_text, falling back to name) with one
431
- * batched Table API query per distinct table.
432
- */
433
- async function collectCatalogVarNames(inspection, ctx) {
434
- const names = new Map();
435
- if (!ctx?.sdk) return names;
436
-
437
- const refs = new Map(); // table -> Set<sys_id>
438
- const TOKEN_RE = /([0-9a-f]{32}):([a-z_]+)/g;
439
-
440
- function addRefsFromValue(value) {
441
- if (!value) return;
442
- for (const m of String(value).matchAll(TOKEN_RE)) {
443
- const [, id, table] = m;
444
- if (!refs.has(table)) refs.set(table, new Set());
445
- refs.get(table).add(id);
446
- }
447
- }
448
-
449
- function walk(items) {
450
- if (!Array.isArray(items)) return;
451
- for (const item of items) {
452
- if (!item || typeof item !== 'object') continue;
453
- if (Array.isArray(item.inputs)) {
454
- for (const input of item.inputs) {
455
- if (!input || typeof input !== 'object') continue;
456
- addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
457
- }
458
- }
459
- if (Array.isArray(item.flowBlock)) walk(item.flowBlock);
460
- }
461
- }
462
-
463
- const payload = inspection?.payload;
464
- if (payload && typeof payload === 'object') {
465
- walk(payload.actionInstances);
466
- if (Array.isArray(payload.flowLogicInstances)) {
467
- for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
468
- }
469
- }
470
- // Fallback table sources may also carry inputs (e.g. decoded logic values)
471
- walk(inspection?.actionInstances);
472
- if (Array.isArray(inspection?.flowLogicInstances)) {
473
- for (const logic of inspection.flowLogicInstances) {
474
- if (logic?._decodedValues?.inputs) {
475
- for (const input of logic._decodedValues.inputs) {
476
- if (!input || typeof input !== 'object') continue;
477
- addRefsFromValue(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
478
- }
479
- }
480
- }
481
- }
482
-
483
- for (const [table, ids] of refs) {
484
- const idList = [...ids];
485
- for (let i = 0; i < idList.length; i += 100) {
486
- const chunk = idList.slice(i, i + 100);
487
- try {
488
- const params = new URLSearchParams();
489
- params.set('sysparm_query', `sys_idIN${chunk.join(',')}`);
490
- params.set('sysparm_fields', 'sys_id,name,question_text');
491
- params.set('sysparm_display_value', 'all');
492
- params.set('sysparm_limit', String(chunk.length));
493
- const records = await ctx.sdk.list(table, params);
494
- for (const r of records) {
495
- const id = getStringField(r, 'sys_id');
496
- const label = firstNonEmpty(getStringField(r, 'question_text'), getStringField(r, 'name'));
497
- if (id && label) names.set(id, label);
498
- }
499
- } catch {
500
- // resolution failed (no access, deleted record) — leave raw sys_ids
501
- }
502
- }
503
- }
504
-
505
- return names;
506
- }
507
-
508
- /**
509
- * Replace catalog variable "sys_id:table" pairs with readable labels when the
510
- * resolver map has them. Returns null when the value isn't a catalog-variable
511
- * list (so callers keep rendering it raw).
512
- */
513
- function resolveCatalogVarValue(value, catalogVarNames) {
514
- if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
515
- const trimmed = String(value).trim();
516
- if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
517
- return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
518
- const label = catalogVarNames.get(id);
519
- return label || m;
520
- });
521
- }
522
-
523
- async function formatFlowStructureFromPayload(payload, ctx) {
524
- const childUIDs = new Set();
525
-
526
- function markChildren(items) {
527
- if (!Array.isArray(items)) return;
528
- for (const item of items) {
529
- if (!item || typeof item !== 'object') continue;
530
- const uid = getStringField(item, 'uiUniqueIdentifier');
531
- if (uid) childUIDs.add(uid);
532
- if (Array.isArray(item.flowBlock)) {
533
- markChildren(item.flowBlock);
534
- }
535
- }
536
- }
537
-
538
- if (Array.isArray(payload.flowLogicInstances)) {
539
- for (const logic of payload.flowLogicInstances) {
540
- if (!logic || typeof logic !== 'object') continue;
541
- if (Array.isArray(logic.flowBlock)) {
542
- markChildren(logic.flowBlock);
543
- }
544
- }
545
- }
546
-
547
- const roots = [];
548
-
549
- function addFromPayload(key, stepType) {
550
- const items = payload[key];
551
- if (!Array.isArray(items)) return;
552
- for (const item of items) {
553
- if (!item || typeof item !== 'object') continue;
554
- const uid = getStringField(item, 'uiUniqueIdentifier');
555
- if (uid && childUIDs.has(uid)) continue;
556
- roots.push({ stepType, data: item, order: parseOrderField(item) });
557
- }
558
- }
559
-
560
- addFromPayload('actionInstances', 'action');
561
- addFromPayload('subFlowInstances', 'subflow');
562
- addFromPayload('flowLogicInstances', 'logic');
563
-
564
- roots.sort((a, b) => a.order - b.order);
565
-
566
- if (roots.length === 0) {
567
- return [' (no steps found)'];
568
- }
569
-
570
- const lines = [];
571
- let stepNum = 1;
572
-
573
- async function walk(steps, indent) {
574
- for (const step of steps) {
575
- const pad = ' '.repeat(indent);
576
- lines.push(...await formatStepLine(stepNum, pad, step, ctx));
577
- stepNum++;
578
-
579
- if (step.stepType !== 'logic') continue;
580
- const block = step.data.flowBlock;
581
- if (!Array.isArray(block) || block.length === 0) continue;
582
-
583
- const children = [];
584
- for (const raw of block) {
585
- if (!raw || typeof raw !== 'object') continue;
586
- children.push({
587
- stepType: classifyPayloadItem(raw),
588
- data: raw,
589
- order: parseOrderField(raw),
590
- });
591
- }
592
- children.sort((a, b) => a.order - b.order);
593
- await walk(children, indent + 1);
594
- }
595
- }
596
-
597
- await walk(roots, 0);
598
- return lines;
599
- }
600
-
601
- function formatFlowStructureFallback(inspection) {
602
- const steps = [];
603
-
604
- if (inspection.actionInstances) {
605
- for (const action of inspection.actionInstances) {
606
- const name = firstNonEmpty(
607
- getNestedString(action, 'action_type', 'display_value'),
608
- getStringField(action, 'name'),
609
- getStringField(action, 'display_text'),
610
- 'Action',
611
- );
612
- steps.push({ order: parseOrderField(action), text: name, comment: getStringField(action, 'comment') });
613
- }
614
- }
615
-
616
- if (inspection.flowLogicInstances) {
617
- for (const logic of inspection.flowLogicInstances) {
618
- const name = firstNonEmpty(
619
- getNestedString(logic, 'logic_definition', 'display_value'),
620
- getStringField(logic, 'name'),
621
- getStringField(logic, 'display_text'),
622
- 'Logic',
623
- );
624
- steps.push({
625
- order: parseOrderField(logic),
626
- text: name,
627
- comment: getStringField(logic, 'comment'),
628
- decoded: logic._decodedValues || null,
629
- });
630
- }
631
- }
632
-
633
- if (inspection.subFlowInstances) {
634
- for (const sf of inspection.subFlowInstances) {
635
- const name = firstNonEmpty(
636
- getNestedString(sf, 'subflow', 'display_value'),
637
- getStringField(sf, 'name'),
638
- getStringField(sf, 'display_text'),
639
- 'Subflow',
640
- );
641
- steps.push({ order: parseOrderField(sf), text: '↪ ' + name });
642
- }
643
- }
644
-
645
- steps.sort((a, b) => a.order - b.order);
646
-
647
- if (steps.length === 0) {
648
- return [' (no steps found)'];
649
- }
650
-
651
- const lines = [];
652
- steps.forEach((step, i) => {
653
- const num = `${i + 1}.`;
654
- // Decoded gzip values carry the same input detail as a V2 payload
655
- if (step.decoded && (step.text === 'If' || step.text === 'Else If')) {
656
- let condition = '';
657
- let conditionLabel = '';
658
- if (Array.isArray(step.decoded.inputs)) {
659
- for (const raw of step.decoded.inputs) {
660
- if (!raw || typeof raw !== 'object') continue;
661
- const inputName = getStringField(raw, 'name');
662
- if (inputName === 'condition') {
663
- condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
664
- }
665
- if (inputName === 'condition_name') {
666
- conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
667
- }
668
- }
669
- }
670
- let displayText = step.text;
671
- if (conditionLabel) {
672
- displayText = `${step.text}: ${conditionLabel}`;
673
- } else if (condition && condition.length < 60) {
674
- displayText = `${step.text}: ${condition}`;
675
- }
676
- lines.push(` ${num} ${displayText}`);
677
- if (condition && condition.length >= 60 && !conditionLabel) {
678
- lines.push(` Condition: ${condition}`);
679
- }
680
- } else if (step.decoded && step.text === 'Set Flow Variables') {
681
- lines.push(` ${num} ${step.text}`);
682
- const vars = step.decoded.variables || step.decoded.flowVariables;
683
- if (Array.isArray(vars) && vars.length > 0) {
684
- lines.push(` Variables Set:`);
685
- for (const raw of vars) {
686
- if (!raw || typeof raw !== 'object') continue;
687
- const varName = getStringField(raw, 'name');
688
- const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
689
- if (!varName) continue;
690
- lines.push(varValue ? ` • ${varName} = ${varValue}` : ` • ${varName}`);
691
- }
692
- }
693
- } else {
694
- lines.push(` ${num} ${step.text}`);
695
- }
696
- if (step.comment) {
697
- lines.push(` Annotation: ${step.comment}`);
698
- }
699
- });
700
-
701
- return lines;
702
- }
703
-
704
- async function formatStepLine(stepNum, pad, step, ctx) {
705
- switch (step.stepType) {
706
- case 'logic':
707
- return formatLogicStep(stepNum, pad, step.data);
708
- case 'subflow':
709
- return formatSubFlowStep(stepNum, pad, step.data, ctx);
710
- default:
711
- return formatActionStepWithChildren(stepNum, pad, step.data, ctx);
712
- }
713
- }
714
-
715
- async function formatActionStepWithChildren(stepNum, pad, action, ctx) {
716
- const lines = formatActionStep(stepNum, pad, action, ctx);
717
- const depth = ctx?.depth ?? 1;
718
- if (!ctx?.sdk?.inspectCustomAction || depth <= 1) return lines;
719
-
720
- const actionName = getActionName(action);
721
- if (!actionName) return lines;
722
-
723
- if (!ctx.customActionCache) ctx.customActionCache = new Map();
724
- if (!ctx.customActionCache.has(actionName)) {
725
- ctx.customActionCache.set(actionName, ctx.sdk.inspectCustomAction(actionName).catch(() => null));
726
- }
727
- const details = await ctx.customActionCache.get(actionName);
728
- const steps = details?.steps;
729
- if (!Array.isArray(steps) || steps.length === 0 || !hasUsefulActionSteps(actionName, steps)) return lines;
730
-
731
- lines.push(`${pad} ↳ Internal action steps`);
732
- const ordered = [...steps].sort((a, b) => parseOrderField(a) - parseOrderField(b));
733
- ordered.forEach((step, index) => {
734
- const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'), 'Action step');
735
- const type = getNestedString(step, 'step_type', 'display_value') || getStringField(step, 'step_type');
736
- const suffix = type ? ` - ${type}` : '';
737
- lines.push(`${pad} Step ${index + 1}: ${label}${suffix}`);
738
- appendStepValues(lines, pad + ' ', step, ctx);
739
- });
740
- return lines;
741
- }
742
-
743
- function hasUsefulActionSteps(actionName, steps) {
744
- if (steps.length !== 1) return true;
745
- const step = steps[0];
746
- const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'))
747
- .replace(/\s+step$/i, '').trim().toLowerCase();
748
- return label !== actionName.trim().toLowerCase();
749
- }
750
-
751
- function appendStepValues(lines, pad, step, ctx) {
752
- for (const field of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) {
753
- const value = getStringField(step, field);
754
- if (!value) continue;
755
- const resolved = resolveGuidPills(value, ctx?.labelCache);
756
- for (const part of resolved.split('^')) {
757
- if (part) lines.push(`${pad}${field}: ${part}`);
758
- }
759
- }
760
- }
761
-
762
- function getActionName(action) {
763
- return firstNonEmpty(
764
- getNestedString(action, 'actionType', 'fName'),
765
- getStringField(action, 'actionName'),
766
- getStringField(action, 'actionInternalName'),
767
- getStringField(action, 'name'),
768
- );
769
- }
770
-
771
- export function formatActionStep(stepNum, pad, action, ctx) {
772
- const lines = [];
773
- let actionName = firstNonEmpty(
774
- getNestedString(action, 'actionType', 'fName'),
775
- getStringField(action, 'actionName'),
776
- getStringField(action, 'actionInternalName'),
777
- getStringField(action, 'name'),
778
- 'Unknown Action',
779
- );
780
-
781
- const idx = actionName.indexOf(' : ');
782
- if (idx > 0) {
783
- actionName = actionName.slice(idx + 3).trim();
784
- }
785
-
786
- let tableName = '';
787
- if (Array.isArray(action.inputs)) {
788
- for (const raw of action.inputs) {
789
- if (!raw || typeof raw !== 'object') continue;
790
- if (getStringField(raw, 'name') === 'table_name') {
791
- tableName = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
792
- break;
793
- }
794
- }
795
- }
796
-
797
- const showsTableSuffix = tableName && (actionName === 'Update Record' || actionName === 'Create or Update Record');
798
- let actionDisplay = actionName;
799
- if (showsTableSuffix) {
800
- actionDisplay = actionName + ' - ' + tableName;
801
- }
802
-
803
- const comment = firstNonEmpty(getStringField(action, 'comment'), getStringField(action, 'displayText'));
804
- if (comment) {
805
- lines.push(`${pad}${stepNum}. ${actionDisplay} (${comment})`);
806
- } else {
807
- lines.push(`${pad}${stepNum}. ${actionDisplay}`);
808
- }
809
-
810
- if (Array.isArray(action.inputs)) {
811
- for (const raw of action.inputs) {
812
- if (!raw || typeof raw !== 'object') continue;
813
- const inputName = getStringField(raw, 'name');
814
- if (inputName === 'table_name' && showsTableSuffix) continue;
815
-
816
- let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
817
-
818
- // Catalog variable selections arrive as "sys_id:table" pairs; resolve
819
- // them to readable names when we have a resolver map (built per flow).
820
- const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
821
- if (resolvedCatalog) inputValue = resolvedCatalog;
822
-
823
- // Opaque guid-prefixed pills ({{<32-hex>.<var>}}) get their human
824
- // label from the payload's label cache. Readable step refs stay raw.
825
- inputValue = resolveGuidPills(inputValue, ctx?.labelCache);
826
-
827
- let label = inputName;
828
- if (raw.parameter && typeof raw.parameter === 'object') {
829
- label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
830
- }
831
-
832
- if (!inputValue) {
833
- lines.push(`${pad} ${label}: (not set)`);
834
- continue;
835
- }
836
-
837
- // ServiceNow writes multi-field payloads (encoded-query style) as
838
- // "field=value^field2=value2". Split those onto one line per field;
839
- // plain long strings stay on a single line, untruncated.
840
- if (inputValue.includes('^')) {
841
- for (const field of inputValue.split('^')) {
842
- if (field) lines.push(`${pad} ${label}: ${field}`);
843
- }
844
- } else {
845
- lines.push(`${pad} ${label}: ${inputValue}`);
846
- }
847
- }
848
- }
849
-
850
- return lines;
851
- }
852
-
853
- export async function formatSubFlowStep(stepNum, pad, subFlow, ctx) {
854
- const lines = [];
855
- const subFlowName = firstNonEmpty(
856
- getNestedString(subFlow, 'subFlowType', 'fName'),
857
- getStringField(subFlow, 'subFlowName'),
858
- getStringField(subFlow, 'subFlowInternalName'),
859
- getNestedString(subFlow, 'subFlow', 'name'),
860
- getStringField(subFlow, 'name'),
861
- 'Unknown Subflow',
862
- );
863
-
864
- const comment = getStringField(subFlow, 'comment');
865
- if (comment) {
866
- lines.push(`${pad}${stepNum}. ↪ ${subFlowName} (${comment})`);
867
- } else {
868
- lines.push(`${pad}${stepNum}. ↪ ${subFlowName}`);
869
- }
870
-
871
- // Recurse into the subflow when depth allows and we haven't already seen it.
872
- // Note: `subflowSysId` on the instance is a snapshot id (sys_hub_flow_snapshot),
873
- // NOT the flow id. The embedded `subFlow.parentFlow` is the real flow sys_id.
874
- const subflowSysId = firstNonEmpty(
875
- getNestedString(subFlow, 'subFlow', 'parentFlow'),
876
- getStringField(subFlow, 'subflowSysId'),
877
- getNestedString(subFlow, 'subFlow', 'sys_id'),
878
- getStringField(subFlow, 'sys_id'),
879
- );
880
- const depth = ctx?.depth ?? 1;
881
- const canRecurse = ctx?.sdk && subflowSysId && depth > 1 && !ctx.visited.has(subflowSysId);
882
-
883
- if (!canRecurse) {
884
- if (!subflowSysId) {
885
- lines.push(`${pad} (subflow definition not found)`);
886
- } else if (depth <= 1) {
887
- lines.push(`${pad} jsn flows show "${subFlowName}"`);
888
- }
889
- return lines;
890
- }
891
-
892
- ctx.visited.add(subflowSysId);
893
- try {
894
- const subInspection = await ctx.sdk.inspectFlow(subflowSysId);
895
- const subCtx = { ...ctx, depth: depth - 1 };
896
- const subLines = await formatFlowStructure(subInspection, subCtx);
897
- if (subLines.length === 0) {
898
- lines.push(`${pad} (no steps found)`);
899
- } else {
900
- const innerPad = pad + ' ';
901
- for (const l of subLines) {
902
- lines.push(innerPad + l.replace(/^ {2}/, ''));
903
- }
904
- }
905
- } catch (e) {
906
- lines.push(`${pad} (could not load subflow: ${e.message})`);
907
- }
908
- return lines;
909
- }
910
-
911
- function formatLogicStep(stepNum, pad, logic) {
912
- const lines = [];
913
- const logicType = firstNonEmpty(getNestedString(logic, 'flowLogicDefinition', 'name'), getStringField(logic, 'name'), 'Logic Step');
914
-
915
- const comment = getStringField(logic, 'comment');
916
- let condition = '';
917
- let conditionLabel = '';
918
-
919
- if (logicType === 'If' || logicType === 'Else If') {
920
- if (Array.isArray(logic.inputs)) {
921
- for (const raw of logic.inputs) {
922
- if (!raw || typeof raw !== 'object') continue;
923
- const inputName = getStringField(raw, 'name');
924
- if (inputName === 'condition') {
925
- condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
926
- }
927
- if (inputName === 'condition_name') {
928
- conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
929
- }
930
- }
931
- }
932
- }
933
-
934
- let displayText = logicType;
935
- if (conditionLabel) {
936
- displayText = logicType + ': ' + conditionLabel;
937
- } else if (condition && condition.length < 60) {
938
- displayText = logicType + ': ' + condition;
939
- }
940
-
941
- lines.push(`${pad}${stepNum}. ${displayText}`);
942
-
943
- if (condition && condition.length >= 60 && !conditionLabel) {
944
- lines.push(`${pad} Condition: ${condition}`);
945
- }
946
- if (comment) {
947
- lines.push(`${pad} Annotation: ${comment}`);
948
- }
949
-
950
- if (logicType === 'Set Flow Variables') {
951
- if (Array.isArray(logic.flowVariables) && logic.flowVariables.length > 0) {
952
- lines.push(`${pad} Variables Set:`);
953
- for (const raw of logic.flowVariables) {
954
- if (!raw || typeof raw !== 'object') continue;
955
- const varName = getStringField(raw, 'name');
956
- const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
957
- if (!varName) continue;
958
- if (varValue) {
959
- lines.push(`${pad} • ${varName} = ${varValue}`);
960
- } else {
961
- lines.push(`${pad} • ${varName}`);
962
- }
963
- }
964
- }
965
- }
966
-
967
- return lines;
968
- }
969
-
970
- function classifyPayloadItem(m) {
971
- if (m.flowLogicDefinition) return 'logic';
972
- if (m.subFlowType) return 'subflow';
973
- if (m.subflowSysId) return 'subflow';
974
- if (m.subFlow) return 'subflow';
975
- return 'action';
976
- }
977
-
978
- function getNestedString(record, parent, field) {
979
- const node = record?.[parent];
980
- if (!node || typeof node !== 'object') return '';
981
- return getStringField(node, field);
982
- }
983
-
984
- function firstNonEmpty(...values) {
985
- for (const v of values) {
986
- if (v && String(v).trim() !== '') return String(v).trim();
987
- }
988
- return '';
989
- }
990
-
991
- function titleCase(s) {
992
- return s
993
- .toLowerCase()
994
- .split(/\s+/)
995
- .map(w => w.charAt(0).toUpperCase() + w.slice(1))
996
- .join(' ');
997
- }
998
-
999
- function formatTriggerCondition(condition) {
1000
- if (!condition) return '';
1001
- let result = condition;
1002
- result = result.replace(/\^OR/g, ' OR ');
1003
- result = result.replace(/\^/g, ' AND ');
1004
- result = result.replace(/!=/g, ' != ');
1005
- result = result.replace(/>=/g, ' >= ');
1006
- result = result.replace(/<=/g, ' <= ');
1007
- result = result.replace(/=/g, ' = ');
1008
- result = result.replace(/>/g, ' > ');
1009
- result = result.replace(/</g, ' < ');
1010
- result = result.replace(/LIKE/g, ' LIKE ');
1011
- while (result.includes(' ')) {
1012
- result = result.replace(/ {2}/g, ' ');
1013
- }
1014
- return result.trim();
1015
- }
1016
-
1017
- function parseOrderField(record) {
1018
- const order = getStringField(record, 'order');
1019
- const n = parseInt(order, 10);
1020
- return isNaN(n) ? 0 : n;
1021
- }