@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.
@@ -0,0 +1,844 @@
1
+ import { getStringField } from './helpers.js';
2
+
3
+ /**
4
+ * Inspect a flow through the narrow remote adapter and render its deep view.
5
+ * The adapter only needs inspectFlow and inspectCustomAction.
6
+ */
7
+ export async function inspectFlow({ adapter, identifier, instanceURL = '', depth = 2 }) {
8
+ if (!adapter || typeof adapter.inspectFlow !== 'function') {
9
+ throw new TypeError('flow inspection adapter must provide inspectFlow');
10
+ }
11
+ const inspection = normalizeInspection(await adapter.inspectFlow(identifier));
12
+ const normalizedDepth = Math.max(1, Math.floor(depth ?? 2));
13
+ const ctx = {
14
+ sdk: adapter,
15
+ adapter,
16
+ instanceURL,
17
+ depth: normalizedDepth,
18
+ visited: new Set([inspection.flow?.sysID]),
19
+ catalogLookupCache: new Map(),
20
+ };
21
+ const formatted = await formatFlowInspection(inspection, ctx);
22
+ return { ...inspection, _formatted: formatted };
23
+ }
24
+
25
+ function normalizeInspection(value) {
26
+ const inspection = value && typeof value === 'object' ? value : {};
27
+ const normalized = {
28
+ ...inspection,
29
+ flow: inspection.flow && typeof inspection.flow === 'object' ? inspection.flow : {},
30
+ payload: inspection.payload && typeof inspection.payload === 'object' && !Array.isArray(inspection.payload) ? inspection.payload : {},
31
+ version: inspection.version && typeof inspection.version === 'object' ? inspection.version : {},
32
+ triggerInstances: Array.isArray(inspection.triggerInstances) ? inspection.triggerInstances : [],
33
+ actionInstances: Array.isArray(inspection.actionInstances) ? inspection.actionInstances : [],
34
+ flowLogicInstances: Array.isArray(inspection.flowLogicInstances) ? inspection.flowLogicInstances : [],
35
+ subFlowInstances: Array.isArray(inspection.subFlowInstances) ? inspection.subFlowInstances : [],
36
+ flowInputs: Array.isArray(inspection.flowInputs) ? inspection.flowInputs : [],
37
+ flowOutputs: Array.isArray(inspection.flowOutputs) ? inspection.flowOutputs : [],
38
+ flowVariables: Array.isArray(inspection.flowVariables) ? inspection.flowVariables : [],
39
+ };
40
+ if (Object.prototype.hasOwnProperty.call(inspection, 'catalogRecords')) {
41
+ Object.defineProperty(normalized, 'catalogRecords', {
42
+ value: inspection.catalogRecords,
43
+ enumerable: false,
44
+ configurable: true,
45
+ });
46
+ }
47
+ return normalized;
48
+ }
49
+
50
+ async function formatFlowInspection(inspection, ctx) {
51
+ const lines = [];
52
+ const instanceURL = ctx?.instanceURL || '';
53
+ const flow = inspection.flow;
54
+
55
+ lines.push('');
56
+ lines.push(`Flow: ${flow.name}`);
57
+ lines.push('');
58
+
59
+ const status = flow.active ? 'Active' : 'Inactive';
60
+ let version = flow.version || inferFlowVersion(inspection);
61
+ lines.push(` Status: ${status} | Version: ${version}`);
62
+ lines.push(` Sys ID: ${flow.sysID}`);
63
+ if (instanceURL && flow.sysID) {
64
+ lines.push(` Link: ${instanceURL}/sys_hub_flow.do?sys_id=${flow.sysID}`);
65
+ }
66
+
67
+ // Flow variables section
68
+ if (inspection.flowVariables && inspection.flowVariables.length > 0) {
69
+ const vars = [...inspection.flowVariables].sort((a, b) => {
70
+ const ao = parseInt(getStringField(a, 'order'), 10) || 0;
71
+ const bo = parseInt(getStringField(b, 'order'), 10) || 0;
72
+ return ao - bo;
73
+ });
74
+ lines.push('');
75
+ lines.push('▶ FLOW VARIABLES');
76
+ lines.push('─'.repeat(50));
77
+ for (const v of vars) {
78
+ const name = firstNonEmpty(getStringField(v, 'label'), getStringField(v, 'name'), 'Variable');
79
+ const typeName = firstNonEmpty(getStringField(v, 'type_label'), getStringField(v, 'type'), '');
80
+ if (typeName) {
81
+ lines.push(` • ${name}: ${typeName}`);
82
+ } else {
83
+ lines.push(` • ${name}`);
84
+ }
85
+ }
86
+ }
87
+
88
+ // Subflow I/O section
89
+ if (flow.type && flow.type.toLowerCase() === 'subflow') {
90
+ lines.push('');
91
+ lines.push('▶ SUBFLOW');
92
+ lines.push('─'.repeat(50));
93
+
94
+ if (inspection.flowInputs && inspection.flowInputs.length > 0) {
95
+ lines.push(` Inputs (${inspection.flowInputs.length})`);
96
+ for (const input of inspection.flowInputs) {
97
+ const name = firstNonEmpty(getStringField(input, 'label'), getStringField(input, 'name'), 'Input');
98
+ const typeName = getStringField(input, 'type');
99
+ if (typeName) {
100
+ lines.push(` • ${name}: ${typeName}`);
101
+ } else {
102
+ lines.push(` • ${name}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ if (inspection.flowOutputs && inspection.flowOutputs.length > 0) {
108
+ if (inspection.flowInputs && inspection.flowInputs.length > 0) {
109
+ lines.push('');
110
+ }
111
+ lines.push(` Outputs (${inspection.flowOutputs.length})`);
112
+ for (const out of inspection.flowOutputs) {
113
+ const name = firstNonEmpty(getStringField(out, 'label'), getStringField(out, 'name'), 'Output');
114
+ const typeName = getStringField(out, 'type');
115
+ if (typeName) {
116
+ lines.push(` • ${name}: ${typeName}`);
117
+ } else {
118
+ lines.push(` • ${name}`);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Trigger section
125
+ const { name: triggerName, type: triggerType, table: triggerTable, time: triggerTime, condition: triggerCondition } = extractTriggerDetails(inspection);
126
+ if (triggerName || triggerType || triggerTable || triggerTime || triggerCondition) {
127
+ lines.push('');
128
+ lines.push('▶ TRIGGER');
129
+ lines.push('─'.repeat(50));
130
+ if (triggerName) lines.push(` Name: ${triggerName}`);
131
+ if (triggerType) lines.push(` Type: ${titleCase(triggerType.replace(/_/g, ' '))}`);
132
+ if (triggerTable) lines.push(` Table: ${triggerTable}`);
133
+ if (triggerTime) lines.push(` Time: ${triggerTime}`);
134
+ if (triggerCondition) lines.push(` Condition: ${formatTriggerCondition(triggerCondition)}`);
135
+ }
136
+
137
+ // Flow structure section
138
+ lines.push('');
139
+ lines.push('⚡ FLOW STRUCTURE');
140
+ lines.push('─'.repeat(50));
141
+ const structureLines = await formatFlowStructure(inspection, ctx);
142
+ lines.push(...structureLines);
143
+ // Note limitation only when we couldn't reconstruct detail from the payload OR the gzip fallback
144
+ const hasPayload = inspection.payload && Object.keys(inspection.payload).length > 0;
145
+ const hasDecodedLogic = (inspection.flowLogicInstances || []).some(l => l._decodedValues);
146
+ if (!hasPayload && !hasDecodedLogic) {
147
+ lines.push('');
148
+ lines.push(' (step detail limited — no flow definition data available)');
149
+ }
150
+
151
+ lines.push('');
152
+ return lines.join('\n') + '\n';
153
+ }
154
+
155
+ function extractTriggerDetails(inspection) {
156
+ let name = '';
157
+ let type = '';
158
+ let table = '';
159
+ let time = '';
160
+ let condition = '';
161
+
162
+ if (inspection.version && typeof inspection.version === 'object') {
163
+ name = getStringField(inspection.version, 'trigger_name');
164
+ type = getStringField(inspection.version, 'trigger_type');
165
+ table = getStringField(inspection.version, 'trigger_table');
166
+ time = getStringField(inspection.version, 'trigger_time');
167
+ }
168
+
169
+ if ((!name || !type || !table || !time || !condition) && Object.keys(inspection.payload).length > 0) {
170
+ const triggers = inspection.payload.triggerInstances;
171
+ if (Array.isArray(triggers) && triggers.length > 0) {
172
+ const trigger = triggers[0];
173
+ if (trigger && typeof trigger === 'object') {
174
+ name = firstNonEmpty(name, getStringField(trigger, 'name'));
175
+ type = firstNonEmpty(type, getStringField(trigger, 'type'));
176
+ if (Array.isArray(trigger.inputs)) {
177
+ for (const input of trigger.inputs) {
178
+ if (!input || typeof input !== 'object') continue;
179
+ const k = getStringField(input, 'name');
180
+ const v = firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value'));
181
+ if (k === 'table') table = firstNonEmpty(table, v);
182
+ if (k === 'time') time = firstNonEmpty(time, v);
183
+ if (k === 'condition') condition = firstNonEmpty(condition, v);
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ if (!name && inspection.triggerInstances && inspection.triggerInstances.length > 0) {
191
+ const first = inspection.triggerInstances[0];
192
+ name = firstNonEmpty(name, getStringField(first, 'name'), getStringField(first, 'display_text'), getNestedString(first, 'trigger_definition', 'display_value'));
193
+ type = firstNonEmpty(type, getStringField(first, 'trigger_type'), getNestedString(first, 'trigger_definition', 'display_value'));
194
+ }
195
+
196
+ if (time.includes(' ')) {
197
+ const parts = time.split(' ');
198
+ if (parts.length === 2) {
199
+ time = parts[1];
200
+ }
201
+ }
202
+
203
+ return { name, type, table, time, condition };
204
+ }
205
+
206
+ function inferFlowVersion(inspection) {
207
+ if (Object.keys(inspection.payload).length > 0) return 'Unset (Assumed V1)';
208
+ if (inspection.actionInstances && inspection.actionInstances.length > 0) return 'Unset (Assumed V1)';
209
+ return 'Unset';
210
+ }
211
+
212
+ async function collectCatalogVarNames(inspection, ctx) {
213
+ const names = new Map();
214
+ const refs = new Map();
215
+ const tokenRe = /([0-9a-f]{32}):([a-z_]+)/g;
216
+ const add = value => {
217
+ if (!value) return;
218
+ for (const [, id, table] of String(value).matchAll(tokenRe)) {
219
+ if (!refs.has(table)) refs.set(table, new Set());
220
+ refs.get(table).add(id);
221
+ }
222
+ };
223
+ const addInputs = inputs => {
224
+ if (!Array.isArray(inputs)) return;
225
+ for (const input of inputs) {
226
+ if (input && typeof input === 'object') add(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
227
+ }
228
+ };
229
+ const walk = items => {
230
+ if (!Array.isArray(items)) return;
231
+ for (const item of items) {
232
+ if (!item || typeof item !== 'object') continue;
233
+ if (Array.isArray(item.inputs)) for (const input of item.inputs) {
234
+ if (input && typeof input === 'object') add(firstNonEmpty(getStringField(input, 'displayValue'), getStringField(input, 'value')));
235
+ }
236
+ walk(item.flowBlock);
237
+ }
238
+ };
239
+ const payload = inspection.payload;
240
+ walk(payload.actionInstances);
241
+ if (Array.isArray(payload.flowLogicInstances)) for (const logic of payload.flowLogicInstances) walk(logic?.flowBlock);
242
+ walk(inspection.actionInstances);
243
+ for (const logic of inspection.flowLogicInstances) addInputs(logic?._decodedValues?.inputs);
244
+ for (const [table, ids] of refs) {
245
+ for (const id of ids) {
246
+ const key = `${table}:${id}`;
247
+ if (!ctx.catalogLookupCache.has(key)) {
248
+ const record = (inspection.catalogRecords || []).find(item => getStringField(item, 'sys_id') === id
249
+ && (!getStringField(item, 'table') || getStringField(item, 'table') === table));
250
+ ctx.catalogLookupCache.set(key, record || null);
251
+ }
252
+ const record = await ctx.catalogLookupCache.get(key);
253
+ const label = record && firstNonEmpty(getStringField(record, 'question_text'), getStringField(record, 'name'));
254
+ if (label) names.set(id, label);
255
+ }
256
+ }
257
+ return names;
258
+ }
259
+
260
+ async function formatFlowStructure(inspection, ctx) {
261
+ const supplied = Array.isArray(inspection.catalogRecords)
262
+ ? await collectCatalogVarNames(inspection, ctx)
263
+ : null;
264
+ const names = supplied instanceof Map
265
+ ? supplied
266
+ : new Map(Object.entries(supplied || {}));
267
+ if (names.size > 0) {
268
+ if (!ctx.catalogVarNames) ctx.catalogVarNames = new Map();
269
+ for (const [k, v] of names) ctx.catalogVarNames.set(k, v);
270
+ }
271
+ const labelCache = buildLabelCache(inspection.payload);
272
+ if (labelCache.size > 0) {
273
+ if (!ctx.labelCache) ctx.labelCache = new Map();
274
+ for (const [k, v] of labelCache) ctx.labelCache.set(k, v);
275
+ }
276
+ const payload = inspection.payload;
277
+ if (isUsableFlowPayload(payload)) {
278
+ return formatFlowStructureFromPayload(payload, ctx);
279
+ }
280
+ return formatFlowStructureFallback(inspection);
281
+ }
282
+
283
+ function isUsableFlowPayload(payload) {
284
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false;
285
+ return ['actionInstances', 'flowLogicInstances', 'subFlowInstances']
286
+ .some(key => Array.isArray(payload[key]) && payload[key].some(item => item && typeof item === 'object' && !Array.isArray(item)));
287
+ }
288
+
289
+ /**
290
+ * The ProcessFlow payload carries a label cache (also serialized as
291
+ * labelCacheAsJsonString) mapping data-pill names like "<step-uid>.<var>"
292
+ * to the human labels the Flow Designer UI shows, e.g.
293
+ * "1 - Get Catalog Variables➛session_title". Build a Map so guid-prefixed
294
+ * pills can be rendered readably.
295
+ */
296
+ function buildLabelCache(payload) {
297
+ const cache = new Map();
298
+ if (!payload || typeof payload !== 'object') return cache;
299
+ let entries = payload.label_cache;
300
+ if (!Array.isArray(entries)) {
301
+ try {
302
+ entries = JSON.parse(payload.labelCacheAsJsonString || '[]');
303
+ } catch {
304
+ entries = [];
305
+ }
306
+ }
307
+ for (const e of entries) {
308
+ if (!e || typeof e !== 'object') continue;
309
+ const name = getStringField(e, 'name');
310
+ const label = getStringField(e, 'label');
311
+ if (name && label) cache.set(name, label);
312
+ }
313
+ return cache;
314
+ }
315
+
316
+ 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;
317
+
318
+ /**
319
+ * Replace opaque guid-prefixed pills ({{<32-hex-guid>.<var>}}) with their
320
+ * human label from the payload's label cache. Readable step refs like
321
+ * {{Created_1.table_name}} are NOT matched and stay raw.
322
+ */
323
+ function resolveGuidPills(value, labelCache) {
324
+ if (!labelCache || labelCache.size === 0 || !value) return value;
325
+ return String(value).replace(GUID_PILL_RE, (m, guid, name) => {
326
+ const label = labelCache.get(`${guid}.${name}`);
327
+ return label ? `{{${label}}}` : m;
328
+ });
329
+ }
330
+
331
+ /**
332
+ * Replace catalog variable "sys_id:table" pairs with readable labels when the
333
+ * resolver map has them. Returns null when the value isn't a catalog-variable
334
+ * list (so callers keep rendering it raw).
335
+ */
336
+ function resolveCatalogVarValue(value, catalogVarNames) {
337
+ if (!catalogVarNames || catalogVarNames.size === 0 || !value) return null;
338
+ const trimmed = String(value).trim();
339
+ if (!/^([0-9a-f]{32}:[a-z_]+)(,[0-9a-f]{32}:[a-z_]+)*,?$/.test(trimmed)) return null;
340
+ return String(value).replace(/([0-9a-f]{32}):[a-z_]+/g, (m, id) => {
341
+ const label = catalogVarNames.get(id);
342
+ return label || m;
343
+ });
344
+ }
345
+
346
+ async function formatFlowStructureFromPayload(payload, ctx) {
347
+ const childUIDs = new Set();
348
+
349
+ function markChildren(items) {
350
+ if (!Array.isArray(items)) return;
351
+ for (const item of items) {
352
+ if (!item || typeof item !== 'object') continue;
353
+ const uid = getStringField(item, 'uiUniqueIdentifier');
354
+ if (uid) childUIDs.add(uid);
355
+ if (Array.isArray(item.flowBlock)) {
356
+ markChildren(item.flowBlock);
357
+ }
358
+ }
359
+ }
360
+
361
+ if (Array.isArray(payload.flowLogicInstances)) {
362
+ for (const logic of payload.flowLogicInstances) {
363
+ if (!logic || typeof logic !== 'object') continue;
364
+ if (Array.isArray(logic.flowBlock)) {
365
+ markChildren(logic.flowBlock);
366
+ }
367
+ }
368
+ }
369
+
370
+ const roots = [];
371
+
372
+ function addFromPayload(key, stepType) {
373
+ const items = payload[key];
374
+ if (!Array.isArray(items)) return;
375
+ for (const item of items) {
376
+ if (!item || typeof item !== 'object') continue;
377
+ const uid = getStringField(item, 'uiUniqueIdentifier');
378
+ if (uid && childUIDs.has(uid)) continue;
379
+ roots.push({ stepType, data: item, order: parseOrderField(item) });
380
+ }
381
+ }
382
+
383
+ addFromPayload('actionInstances', 'action');
384
+ addFromPayload('subFlowInstances', 'subflow');
385
+ addFromPayload('flowLogicInstances', 'logic');
386
+
387
+ roots.sort((a, b) => a.order - b.order);
388
+
389
+ if (roots.length === 0) {
390
+ return [' (no steps found)'];
391
+ }
392
+
393
+ const lines = [];
394
+ let stepNum = 1;
395
+
396
+ async function walk(steps, indent) {
397
+ for (const step of steps) {
398
+ const pad = ' '.repeat(indent);
399
+ lines.push(...await formatStepLine(stepNum, pad, step, ctx));
400
+ stepNum++;
401
+
402
+ if (step.stepType !== 'logic') continue;
403
+ const block = step.data.flowBlock;
404
+ if (!Array.isArray(block) || block.length === 0) continue;
405
+
406
+ const children = [];
407
+ for (const raw of block) {
408
+ if (!raw || typeof raw !== 'object') continue;
409
+ children.push({
410
+ stepType: classifyPayloadItem(raw),
411
+ data: raw,
412
+ order: parseOrderField(raw),
413
+ });
414
+ }
415
+ children.sort((a, b) => a.order - b.order);
416
+ await walk(children, indent + 1);
417
+ }
418
+ }
419
+
420
+ await walk(roots, 0);
421
+ return lines;
422
+ }
423
+
424
+ function formatFlowStructureFallback(inspection) {
425
+ const steps = [];
426
+
427
+ if (inspection.actionInstances) {
428
+ for (const action of inspection.actionInstances) {
429
+ const name = firstNonEmpty(
430
+ getNestedString(action, 'action_type', 'display_value'),
431
+ getStringField(action, 'name'),
432
+ getStringField(action, 'display_text'),
433
+ 'Action',
434
+ );
435
+ steps.push({ order: parseOrderField(action), text: name, comment: getStringField(action, 'comment') });
436
+ }
437
+ }
438
+
439
+ if (inspection.flowLogicInstances) {
440
+ for (const logic of inspection.flowLogicInstances) {
441
+ const name = firstNonEmpty(
442
+ getNestedString(logic, 'logic_definition', 'display_value'),
443
+ getStringField(logic, 'name'),
444
+ getStringField(logic, 'display_text'),
445
+ 'Logic',
446
+ );
447
+ steps.push({
448
+ order: parseOrderField(logic),
449
+ text: name,
450
+ comment: getStringField(logic, 'comment'),
451
+ decoded: logic._decodedValues || null,
452
+ });
453
+ }
454
+ }
455
+
456
+ if (inspection.subFlowInstances) {
457
+ for (const sf of inspection.subFlowInstances) {
458
+ const name = firstNonEmpty(
459
+ getNestedString(sf, 'subflow', 'display_value'),
460
+ getStringField(sf, 'name'),
461
+ getStringField(sf, 'display_text'),
462
+ 'Subflow',
463
+ );
464
+ steps.push({ order: parseOrderField(sf), text: '↪ ' + name });
465
+ }
466
+ }
467
+
468
+ steps.sort((a, b) => a.order - b.order);
469
+
470
+ if (steps.length === 0) {
471
+ return [' (no steps found)'];
472
+ }
473
+
474
+ const lines = [];
475
+ steps.forEach((step, i) => {
476
+ const num = `${i + 1}.`;
477
+ // Decoded gzip values carry the same input detail as a V2 payload
478
+ if (step.decoded && (step.text === 'If' || step.text === 'Else If')) {
479
+ let condition = '';
480
+ let conditionLabel = '';
481
+ if (Array.isArray(step.decoded.inputs)) {
482
+ for (const raw of step.decoded.inputs) {
483
+ if (!raw || typeof raw !== 'object') continue;
484
+ const inputName = getStringField(raw, 'name');
485
+ if (inputName === 'condition') {
486
+ condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
487
+ }
488
+ if (inputName === 'condition_name') {
489
+ conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
490
+ }
491
+ }
492
+ }
493
+ let displayText = step.text;
494
+ if (conditionLabel) {
495
+ displayText = `${step.text}: ${conditionLabel}`;
496
+ } else if (condition && condition.length < 60) {
497
+ displayText = `${step.text}: ${condition}`;
498
+ }
499
+ lines.push(` ${num} ${displayText}`);
500
+ if (condition && condition.length >= 60 && !conditionLabel) {
501
+ lines.push(` Condition: ${condition}`);
502
+ }
503
+ } else if (step.decoded && step.text === 'Set Flow Variables') {
504
+ lines.push(` ${num} ${step.text}`);
505
+ const vars = step.decoded.variables || step.decoded.flowVariables;
506
+ if (Array.isArray(vars) && vars.length > 0) {
507
+ lines.push(` Variables Set:`);
508
+ for (const raw of vars) {
509
+ if (!raw || typeof raw !== 'object') continue;
510
+ const varName = getStringField(raw, 'name');
511
+ const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
512
+ if (!varName) continue;
513
+ lines.push(varValue ? ` • ${varName} = ${varValue}` : ` • ${varName}`);
514
+ }
515
+ }
516
+ } else {
517
+ lines.push(` ${num} ${step.text}`);
518
+ }
519
+ if (step.comment) {
520
+ lines.push(` Annotation: ${step.comment}`);
521
+ }
522
+ });
523
+
524
+ return lines;
525
+ }
526
+
527
+ async function formatStepLine(stepNum, pad, step, ctx) {
528
+ switch (step.stepType) {
529
+ case 'logic':
530
+ return formatLogicStep(stepNum, pad, step.data);
531
+ case 'subflow':
532
+ return formatSubFlowStep(stepNum, pad, step.data, ctx);
533
+ default:
534
+ return formatActionStepWithChildren(stepNum, pad, step.data, ctx);
535
+ }
536
+ }
537
+
538
+ async function formatActionStepWithChildren(stepNum, pad, action, ctx) {
539
+ const lines = formatActionStep(stepNum, pad, action, ctx);
540
+ const depth = ctx?.depth ?? 1;
541
+ if (!ctx?.sdk?.inspectCustomAction || depth <= 1) return lines;
542
+
543
+ const actionName = getActionName(action);
544
+ if (!actionName) return lines;
545
+
546
+ if (!ctx.customActionCache) ctx.customActionCache = new Map();
547
+ if (!ctx.customActionCache.has(actionName)) {
548
+ ctx.customActionCache.set(actionName, ctx.sdk.inspectCustomAction(actionName).catch(() => null));
549
+ }
550
+ const details = await ctx.customActionCache.get(actionName);
551
+ const steps = details?.steps;
552
+ if (!Array.isArray(steps) || steps.length === 0 || !hasUsefulActionSteps(actionName, steps)) return lines;
553
+
554
+ lines.push(`${pad} ↳ Internal action steps`);
555
+ const ordered = [...steps].sort((a, b) => parseOrderField(a) - parseOrderField(b));
556
+ ordered.forEach((step, index) => {
557
+ const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'), 'Action step');
558
+ const type = getNestedString(step, 'step_type', 'display_value') || getStringField(step, 'step_type');
559
+ const suffix = type ? ` - ${type}` : '';
560
+ lines.push(`${pad} Step ${index + 1}: ${label}${suffix}`);
561
+ appendStepValues(lines, pad + ' ', step, ctx);
562
+ });
563
+ return lines;
564
+ }
565
+
566
+ function hasUsefulActionSteps(actionName, steps) {
567
+ if (steps.length !== 1) return true;
568
+ const step = steps[0];
569
+ const label = firstNonEmpty(getStringField(step, 'label'), getStringField(step, 'name'))
570
+ .replace(/\s+step$/i, '').trim().toLowerCase();
571
+ return label !== actionName.trim().toLowerCase();
572
+ }
573
+
574
+ function appendStepValues(lines, pad, step, ctx) {
575
+ for (const field of ['inputs', 'outputs', 'extended_inputs', 'extended_outputs']) {
576
+ const value = getStringField(step, field);
577
+ if (!value) continue;
578
+ const resolved = resolveGuidPills(value, ctx?.labelCache);
579
+ for (const part of resolved.split('^')) {
580
+ if (part) lines.push(`${pad}${field}: ${part}`);
581
+ }
582
+ }
583
+ }
584
+
585
+ function getActionName(action) {
586
+ return firstNonEmpty(
587
+ getNestedString(action, 'actionType', 'fName'),
588
+ getStringField(action, 'actionName'),
589
+ getStringField(action, 'actionInternalName'),
590
+ getStringField(action, 'name'),
591
+ );
592
+ }
593
+
594
+ function formatActionStep(stepNum, pad, action, ctx) {
595
+ const lines = [];
596
+ let actionName = firstNonEmpty(
597
+ getNestedString(action, 'actionType', 'fName'),
598
+ getStringField(action, 'actionName'),
599
+ getStringField(action, 'actionInternalName'),
600
+ getStringField(action, 'name'),
601
+ 'Unknown Action',
602
+ );
603
+
604
+ const idx = actionName.indexOf(' : ');
605
+ if (idx > 0) {
606
+ actionName = actionName.slice(idx + 3).trim();
607
+ }
608
+
609
+ let tableName = '';
610
+ if (Array.isArray(action.inputs)) {
611
+ for (const raw of action.inputs) {
612
+ if (!raw || typeof raw !== 'object') continue;
613
+ if (getStringField(raw, 'name') === 'table_name') {
614
+ tableName = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
615
+ break;
616
+ }
617
+ }
618
+ }
619
+
620
+ const showsTableSuffix = tableName && (actionName === 'Update Record' || actionName === 'Create or Update Record');
621
+ let actionDisplay = actionName;
622
+ if (showsTableSuffix) {
623
+ actionDisplay = actionName + ' - ' + tableName;
624
+ }
625
+
626
+ const comment = firstNonEmpty(getStringField(action, 'comment'), getStringField(action, 'displayText'));
627
+ if (comment) {
628
+ lines.push(`${pad}${stepNum}. ${actionDisplay} (${comment})`);
629
+ } else {
630
+ lines.push(`${pad}${stepNum}. ${actionDisplay}`);
631
+ }
632
+
633
+ if (Array.isArray(action.inputs)) {
634
+ for (const raw of action.inputs) {
635
+ if (!raw || typeof raw !== 'object') continue;
636
+ const inputName = getStringField(raw, 'name');
637
+ if (inputName === 'table_name' && showsTableSuffix) continue;
638
+
639
+ let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
640
+
641
+ // Catalog variable selections arrive as "sys_id:table" pairs; resolve
642
+ // them to readable names when we have a resolver map (built per flow).
643
+ const resolvedCatalog = resolveCatalogVarValue(inputValue, ctx?.catalogVarNames);
644
+ if (resolvedCatalog) inputValue = resolvedCatalog;
645
+
646
+ // Opaque guid-prefixed pills ({{<32-hex>.<var>}}) get their human
647
+ // label from the payload's label cache. Readable step refs stay raw.
648
+ inputValue = resolveGuidPills(inputValue, ctx?.labelCache);
649
+
650
+ let label = inputName;
651
+ if (raw.parameter && typeof raw.parameter === 'object') {
652
+ label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
653
+ }
654
+
655
+ if (!inputValue) {
656
+ lines.push(`${pad} ${label}: (not set)`);
657
+ continue;
658
+ }
659
+
660
+ // ServiceNow writes multi-field payloads (encoded-query style) as
661
+ // "field=value^field2=value2". Split those onto one line per field;
662
+ // plain long strings stay on a single line, untruncated.
663
+ if (inputValue.includes('^')) {
664
+ for (const field of inputValue.split('^')) {
665
+ if (field) lines.push(`${pad} ${label}: ${field}`);
666
+ }
667
+ } else {
668
+ lines.push(`${pad} ${label}: ${inputValue}`);
669
+ }
670
+ }
671
+ }
672
+
673
+ return lines;
674
+ }
675
+
676
+ async function formatSubFlowStep(stepNum, pad, subFlow, ctx) {
677
+ const lines = [];
678
+ const subFlowName = firstNonEmpty(
679
+ getNestedString(subFlow, 'subFlowType', 'fName'),
680
+ getStringField(subFlow, 'subFlowName'),
681
+ getStringField(subFlow, 'subFlowInternalName'),
682
+ getNestedString(subFlow, 'subFlow', 'name'),
683
+ getStringField(subFlow, 'name'),
684
+ 'Unknown Subflow',
685
+ );
686
+
687
+ const comment = getStringField(subFlow, 'comment');
688
+ if (comment) {
689
+ lines.push(`${pad}${stepNum}. ↪ ${subFlowName} (${comment})`);
690
+ } else {
691
+ lines.push(`${pad}${stepNum}. ↪ ${subFlowName}`);
692
+ }
693
+
694
+ // Recurse into the subflow when depth allows and we haven't already seen it.
695
+ // Note: `subflowSysId` on the instance is a snapshot id (sys_hub_flow_snapshot),
696
+ // NOT the flow id. The embedded `subFlow.parentFlow` is the real flow sys_id.
697
+ const subflowSysId = firstNonEmpty(
698
+ getNestedString(subFlow, 'subFlow', 'parentFlow'),
699
+ getStringField(subFlow, 'subflowSysId'),
700
+ getNestedString(subFlow, 'subFlow', 'sys_id'),
701
+ getStringField(subFlow, 'sys_id'),
702
+ );
703
+ const depth = ctx?.depth ?? 1;
704
+ const canRecurse = ctx?.sdk && subflowSysId && depth > 1 && !ctx.visited.has(subflowSysId);
705
+
706
+ if (!canRecurse) {
707
+ if (!subflowSysId) {
708
+ lines.push(`${pad} (subflow definition not found)`);
709
+ } else if (depth <= 1) {
710
+ lines.push(`${pad} jsn flows show "${subFlowName}"`);
711
+ }
712
+ return lines;
713
+ }
714
+
715
+ ctx.visited.add(subflowSysId);
716
+ try {
717
+ const subInspection = normalizeInspection(await ctx.sdk.inspectFlow(subflowSysId));
718
+ const subCtx = { ...ctx, depth: depth - 1 };
719
+ const subLines = await formatFlowStructure(subInspection, subCtx);
720
+ if (subLines.length === 0) {
721
+ lines.push(`${pad} (no steps found)`);
722
+ } else {
723
+ const innerPad = pad + ' ';
724
+ for (const l of subLines) {
725
+ lines.push(innerPad + l.replace(/^ {2}/, ''));
726
+ }
727
+ }
728
+ } catch (e) {
729
+ lines.push(`${pad} (could not load subflow: ${e.message})`);
730
+ }
731
+ return lines;
732
+ }
733
+
734
+ function formatLogicStep(stepNum, pad, logic) {
735
+ const lines = [];
736
+ const logicType = firstNonEmpty(getNestedString(logic, 'flowLogicDefinition', 'name'), getStringField(logic, 'name'), 'Logic Step');
737
+
738
+ const comment = getStringField(logic, 'comment');
739
+ let condition = '';
740
+ let conditionLabel = '';
741
+
742
+ if (logicType === 'If' || logicType === 'Else If') {
743
+ if (Array.isArray(logic.inputs)) {
744
+ for (const raw of logic.inputs) {
745
+ if (!raw || typeof raw !== 'object') continue;
746
+ const inputName = getStringField(raw, 'name');
747
+ if (inputName === 'condition') {
748
+ condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
749
+ }
750
+ if (inputName === 'condition_name') {
751
+ conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
752
+ }
753
+ }
754
+ }
755
+ }
756
+
757
+ let displayText = logicType;
758
+ if (conditionLabel) {
759
+ displayText = logicType + ': ' + conditionLabel;
760
+ } else if (condition && condition.length < 60) {
761
+ displayText = logicType + ': ' + condition;
762
+ }
763
+
764
+ lines.push(`${pad}${stepNum}. ${displayText}`);
765
+
766
+ if (condition && condition.length >= 60 && !conditionLabel) {
767
+ lines.push(`${pad} Condition: ${condition}`);
768
+ }
769
+ if (comment) {
770
+ lines.push(`${pad} Annotation: ${comment}`);
771
+ }
772
+
773
+ if (logicType === 'Set Flow Variables') {
774
+ if (Array.isArray(logic.flowVariables) && logic.flowVariables.length > 0) {
775
+ lines.push(`${pad} Variables Set:`);
776
+ for (const raw of logic.flowVariables) {
777
+ if (!raw || typeof raw !== 'object') continue;
778
+ const varName = getStringField(raw, 'name');
779
+ const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
780
+ if (!varName) continue;
781
+ if (varValue) {
782
+ lines.push(`${pad} • ${varName} = ${varValue}`);
783
+ } else {
784
+ lines.push(`${pad} • ${varName}`);
785
+ }
786
+ }
787
+ }
788
+ }
789
+
790
+ return lines;
791
+ }
792
+
793
+ function classifyPayloadItem(m) {
794
+ if (m.flowLogicDefinition) return 'logic';
795
+ if (m.subFlowType) return 'subflow';
796
+ if (m.subflowSysId) return 'subflow';
797
+ if (m.subFlow) return 'subflow';
798
+ return 'action';
799
+ }
800
+
801
+ function getNestedString(record, parent, field) {
802
+ const node = record?.[parent];
803
+ if (!node || typeof node !== 'object') return '';
804
+ return getStringField(node, field);
805
+ }
806
+
807
+ function firstNonEmpty(...values) {
808
+ for (const v of values) {
809
+ if (v && String(v).trim() !== '') return String(v).trim();
810
+ }
811
+ return '';
812
+ }
813
+
814
+ function titleCase(s) {
815
+ return s
816
+ .toLowerCase()
817
+ .split(/\s+/)
818
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
819
+ .join(' ');
820
+ }
821
+
822
+ function formatTriggerCondition(condition) {
823
+ if (!condition) return '';
824
+ let result = condition;
825
+ result = result.replace(/\^OR/g, ' OR ');
826
+ result = result.replace(/\^/g, ' AND ');
827
+ result = result.replace(/!=/g, ' != ');
828
+ result = result.replace(/>=/g, ' >= ');
829
+ result = result.replace(/<=/g, ' <= ');
830
+ result = result.replace(/=/g, ' = ');
831
+ result = result.replace(/>/g, ' > ');
832
+ result = result.replace(/</g, ' < ');
833
+ result = result.replace(/LIKE/g, ' LIKE ');
834
+ while (result.includes(' ')) {
835
+ result = result.replace(/ {2}/g, ' ');
836
+ }
837
+ return result.trim();
838
+ }
839
+
840
+ function parseOrderField(record) {
841
+ const order = getStringField(record, 'order');
842
+ const n = parseInt(order, 10);
843
+ return isNaN(n) ? 0 : n;
844
+ }