@jacebenson/jsn 3.4.1 → 3.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "3.4.1",
3
+ "version": "3.4.3",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,12 @@ export function flowsCmd(wrap) {
14
14
  builder: (y) => y
15
15
  .option('query', { type: 'string', describe: 'Encoded query (e.g. "nameLIKEincident" or "active=true")' })
16
16
  .option('columns', { alias: ['c', 'fields'], type: 'string', describe: 'Comma-separated columns (e.g. "number,short_description")' })
17
- .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' }),
17
+ .option('limit', { alias: 'l', type: 'number', default: 50, describe: 'Max records' })
18
+ .option('depth', {
19
+ type: 'number',
20
+ default: 2,
21
+ describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
22
+ }),
18
23
  handler: wrap(async (argv, app) => {
19
24
  app.requireInstance();
20
25
  const columns = argv.columns ? argv.columns.split(',') : ['name', 'active', 'description', 'sys_created_by', 'sys_updated_on'];
@@ -28,7 +33,13 @@ export function flowsCmd(wrap) {
28
33
  if (picked === undefined) return; // user cancelled
29
34
  if (picked) {
30
35
  const inspection = await app.sdk.inspectFlow(getStringField(picked, 'sys_id'));
31
- const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
36
+ const ctx = {
37
+ sdk: app.sdk,
38
+ instanceURL: app.getEffectiveInstance(),
39
+ depth: Math.max(1, Math.floor(argv.depth ?? 2)),
40
+ visited: new Set([inspection.flow.sysID]),
41
+ };
42
+ const formatted = await formatFlowInspection(inspection, ctx);
32
43
  return app.ok({ ...inspection, _formatted: formatted }, {
33
44
  summary: `Flow: ${inspection.flow.name}`,
34
45
  breadcrumbs: [{ action: 'list', cmd: 'jsn flows list', description: 'Back to all flows' }],
@@ -56,9 +67,21 @@ export function flowsCmd(wrap) {
56
67
  command: 'show <identifier>',
57
68
  aliases: ['get'],
58
69
  describe: 'Show flow details by name or sys_id',
70
+ builder: (y) => y
71
+ .option('depth', {
72
+ type: 'number',
73
+ default: 2,
74
+ describe: 'Expand nested subflows to this depth (1 = subflow names only, 2 = one level, 3+ = deeper)',
75
+ }),
59
76
  handler: wrap(async (argv, app) => {
60
77
  const inspection = await app.sdk.inspectFlow(argv.identifier);
61
- const formatted = formatFlowInspection(inspection, app.getEffectiveInstance());
78
+ const ctx = {
79
+ sdk: app.sdk,
80
+ instanceURL: app.getEffectiveInstance(),
81
+ depth: Math.max(1, Math.floor(argv.depth ?? 2)),
82
+ visited: new Set([inspection.flow.sysID]),
83
+ };
84
+ const formatted = await formatFlowInspection(inspection, ctx);
62
85
  const data = { ...inspection, _formatted: formatted };
63
86
  app.ok(data, {
64
87
  summary: `Flow: ${inspection.flow.name}`,
@@ -109,15 +132,16 @@ export function flowsCmd(wrap) {
109
132
  console.log('');
110
133
  console.log('Run "jsn flows <command> --help" for details.');
111
134
  console.log('');
112
- console.log('Note: Flow inspection detail varies. V2 flows (with payload)');
113
- console.log('show full action inputs and conditions. Subflows and V1 flows');
114
- console.log('show step names only.');
135
+ console.log('Note: Flow structure comes from the ProcessFlow API (the Flow');
136
+ console.log('Designer UI source), so V1, V2, and subflow definitions all');
137
+ console.log('render with full action inputs and conditions.');
115
138
  },
116
139
  };
117
140
  }
118
141
 
119
- function formatFlowInspection(inspection, instanceURL) {
142
+ export async function formatFlowInspection(inspection, ctx) {
120
143
  const lines = [];
144
+ const instanceURL = ctx?.instanceURL || '';
121
145
  const flow = inspection.flow;
122
146
 
123
147
  lines.push('');
@@ -132,6 +156,27 @@ function formatFlowInspection(inspection, instanceURL) {
132
156
  lines.push(` Link: ${instanceURL}/sys_hub_flow.do?sys_id=${flow.sysID}`);
133
157
  }
134
158
 
159
+ // Flow variables section
160
+ if (inspection.flowVariables && inspection.flowVariables.length > 0) {
161
+ const vars = [...inspection.flowVariables].sort((a, b) => {
162
+ const ao = parseInt(getStringField(a, 'order'), 10) || 0;
163
+ const bo = parseInt(getStringField(b, 'order'), 10) || 0;
164
+ return ao - bo;
165
+ });
166
+ lines.push('');
167
+ lines.push('▶ FLOW VARIABLES');
168
+ lines.push('─'.repeat(50));
169
+ for (const v of vars) {
170
+ const name = firstNonEmpty(getStringField(v, 'label'), getStringField(v, 'name'), 'Variable');
171
+ const typeName = firstNonEmpty(getStringField(v, 'type_label'), getStringField(v, 'type'), '');
172
+ if (typeName) {
173
+ lines.push(` • ${name}: ${typeName}`);
174
+ } else {
175
+ lines.push(` • ${name}`);
176
+ }
177
+ }
178
+ }
179
+
135
180
  // Subflow I/O section
136
181
  if (flow.type && flow.type.toLowerCase() === 'subflow') {
137
182
  lines.push('');
@@ -185,12 +230,14 @@ function formatFlowInspection(inspection, instanceURL) {
185
230
  lines.push('');
186
231
  lines.push('⚡ FLOW STRUCTURE');
187
232
  lines.push('─'.repeat(50));
188
- const structureLines = formatFlowStructure(inspection);
233
+ const structureLines = await formatFlowStructure(inspection, ctx);
189
234
  lines.push(...structureLines);
190
- // Note limitation for V1/subflows without payload detail
191
- if (!inspection.payload || Object.keys(inspection.payload).length === 0) {
235
+ // Note limitation only when we couldn't reconstruct detail from the payload OR the gzip fallback
236
+ const hasPayload = inspection.payload && Object.keys(inspection.payload).length > 0;
237
+ const hasDecodedLogic = (inspection.flowLogicInstances || []).some(l => l._decodedValues);
238
+ if (!hasPayload && !hasDecodedLogic) {
192
239
  lines.push('');
193
- lines.push(' (step detail limited — this flow lacks a V2 payload)');
240
+ lines.push(' (step detail limited — no flow definition data available)');
194
241
  }
195
242
 
196
243
  lines.push('');
@@ -254,15 +301,15 @@ function inferFlowVersion(inspection) {
254
301
  return 'Unset';
255
302
  }
256
303
 
257
- function formatFlowStructure(inspection) {
304
+ async function formatFlowStructure(inspection, ctx) {
258
305
  const payload = inspection.payload;
259
306
  if (Object.keys(payload).length > 0) {
260
- return formatFlowStructureFromPayload(payload);
307
+ return formatFlowStructureFromPayload(payload, ctx);
261
308
  }
262
309
  return formatFlowStructureFallback(inspection);
263
310
  }
264
311
 
265
- function formatFlowStructureFromPayload(payload) {
312
+ async function formatFlowStructureFromPayload(payload, ctx) {
266
313
  const childUIDs = new Set();
267
314
 
268
315
  function markChildren(items) {
@@ -312,10 +359,10 @@ function formatFlowStructureFromPayload(payload) {
312
359
  const lines = [];
313
360
  let stepNum = 1;
314
361
 
315
- function walk(steps, indent) {
362
+ async function walk(steps, indent) {
316
363
  for (const step of steps) {
317
364
  const pad = ' '.repeat(indent);
318
- lines.push(...formatStepLine(stepNum, pad, step));
365
+ lines.push(...await formatStepLine(stepNum, pad, step, ctx));
319
366
  stepNum++;
320
367
 
321
368
  if (step.stepType !== 'logic') continue;
@@ -332,11 +379,11 @@ function formatFlowStructureFromPayload(payload) {
332
379
  });
333
380
  }
334
381
  children.sort((a, b) => a.order - b.order);
335
- walk(children, indent + 1);
382
+ await walk(children, indent + 1);
336
383
  }
337
384
  }
338
385
 
339
- walk(roots, 0);
386
+ await walk(roots, 0);
340
387
  return lines;
341
388
  }
342
389
 
@@ -351,7 +398,7 @@ function formatFlowStructureFallback(inspection) {
351
398
  getStringField(action, 'display_text'),
352
399
  'Action',
353
400
  );
354
- steps.push({ order: parseOrderField(action), text: name });
401
+ steps.push({ order: parseOrderField(action), text: name, comment: getStringField(action, 'comment') });
355
402
  }
356
403
  }
357
404
 
@@ -363,7 +410,12 @@ function formatFlowStructureFallback(inspection) {
363
410
  getStringField(logic, 'display_text'),
364
411
  'Logic',
365
412
  );
366
- steps.push({ order: parseOrderField(logic), text: name });
413
+ steps.push({
414
+ order: parseOrderField(logic),
415
+ text: name,
416
+ comment: getStringField(logic, 'comment'),
417
+ decoded: logic._decodedValues || null,
418
+ });
367
419
  }
368
420
  }
369
421
 
@@ -385,21 +437,71 @@ function formatFlowStructureFallback(inspection) {
385
437
  return [' (no steps found)'];
386
438
  }
387
439
 
388
- return steps.map((step, i) => `${i + 1}. ${step.text}`);
440
+ const lines = [];
441
+ steps.forEach((step, i) => {
442
+ const num = `${i + 1}.`;
443
+ // Decoded gzip values carry the same input detail as a V2 payload
444
+ if (step.decoded && (step.text === 'If' || step.text === 'Else If')) {
445
+ let condition = '';
446
+ let conditionLabel = '';
447
+ if (Array.isArray(step.decoded.inputs)) {
448
+ for (const raw of step.decoded.inputs) {
449
+ if (!raw || typeof raw !== 'object') continue;
450
+ const inputName = getStringField(raw, 'name');
451
+ if (inputName === 'condition') {
452
+ condition = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
453
+ }
454
+ if (inputName === 'condition_name') {
455
+ conditionLabel = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
456
+ }
457
+ }
458
+ }
459
+ let displayText = step.text;
460
+ if (conditionLabel) {
461
+ displayText = `${step.text}: ${conditionLabel}`;
462
+ } else if (condition && condition.length < 60) {
463
+ displayText = `${step.text}: ${condition}`;
464
+ }
465
+ lines.push(` ${num} ${displayText}`);
466
+ if (condition && condition.length >= 60 && !conditionLabel) {
467
+ lines.push(` Condition: ${condition}`);
468
+ }
469
+ } else if (step.decoded && step.text === 'Set Flow Variables') {
470
+ lines.push(` ${num} ${step.text}`);
471
+ const vars = step.decoded.variables || step.decoded.flowVariables;
472
+ if (Array.isArray(vars) && vars.length > 0) {
473
+ lines.push(` Variables Set:`);
474
+ for (const raw of vars) {
475
+ if (!raw || typeof raw !== 'object') continue;
476
+ const varName = getStringField(raw, 'name');
477
+ const varValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
478
+ if (!varName) continue;
479
+ lines.push(varValue ? ` • ${varName} = ${varValue}` : ` • ${varName}`);
480
+ }
481
+ }
482
+ } else {
483
+ lines.push(` ${num} ${step.text}`);
484
+ }
485
+ if (step.comment) {
486
+ lines.push(` Annotation: ${step.comment}`);
487
+ }
488
+ });
489
+
490
+ return lines;
389
491
  }
390
492
 
391
- function formatStepLine(stepNum, pad, step) {
493
+ async function formatStepLine(stepNum, pad, step, ctx) {
392
494
  switch (step.stepType) {
393
495
  case 'logic':
394
496
  return formatLogicStep(stepNum, pad, step.data);
395
497
  case 'subflow':
396
- return formatSubFlowStep(stepNum, pad, step.data);
498
+ return formatSubFlowStep(stepNum, pad, step.data, ctx);
397
499
  default:
398
500
  return formatActionStep(stepNum, pad, step.data);
399
501
  }
400
502
  }
401
503
 
402
- function formatActionStep(stepNum, pad, action) {
504
+ export function formatActionStep(stepNum, pad, action) {
403
505
  const lines = [];
404
506
  let actionName = firstNonEmpty(
405
507
  getNestedString(action, 'actionType', 'fName'),
@@ -425,8 +527,9 @@ function formatActionStep(stepNum, pad, action) {
425
527
  }
426
528
  }
427
529
 
530
+ const showsTableSuffix = tableName && (actionName === 'Update Record' || actionName === 'Create or Update Record');
428
531
  let actionDisplay = actionName;
429
- if (tableName && actionName === 'Update Record') {
532
+ if (showsTableSuffix) {
430
533
  actionDisplay = actionName + ' - ' + tableName;
431
534
  }
432
535
 
@@ -441,27 +544,33 @@ function formatActionStep(stepNum, pad, action) {
441
544
  for (const raw of action.inputs) {
442
545
  if (!raw || typeof raw !== 'object') continue;
443
546
  const inputName = getStringField(raw, 'name');
444
- if (inputName === 'table_name') continue;
547
+ if (inputName === 'table_name' && showsTableSuffix) continue;
445
548
 
446
549
  let inputValue = firstNonEmpty(getStringField(raw, 'displayValue'), getStringField(raw, 'value'));
447
550
  if (!inputValue) continue;
448
- if (inputValue.length > 50) {
449
- inputValue = inputValue.slice(0, 47) + '...';
450
- }
451
551
 
452
552
  let label = inputName;
453
553
  if (raw.parameter && typeof raw.parameter === 'object') {
454
554
  label = firstNonEmpty(getStringField(raw.parameter, 'label'), label);
455
555
  }
456
556
 
457
- lines.push(`${pad} ${label}: ${inputValue}`);
557
+ // ServiceNow writes multi-field payloads (encoded-query style) as
558
+ // "field=value^field2=value2". Split those onto one line per field;
559
+ // plain long strings stay on a single line, untruncated.
560
+ if (inputValue.includes('^')) {
561
+ for (const field of inputValue.split('^')) {
562
+ if (field) lines.push(`${pad} ${label}: ${field}`);
563
+ }
564
+ } else {
565
+ lines.push(`${pad} ${label}: ${inputValue}`);
566
+ }
458
567
  }
459
568
  }
460
569
 
461
570
  return lines;
462
571
  }
463
572
 
464
- function formatSubFlowStep(stepNum, pad, subFlow) {
573
+ export async function formatSubFlowStep(stepNum, pad, subFlow, ctx) {
465
574
  const lines = [];
466
575
  const subFlowName = firstNonEmpty(
467
576
  getNestedString(subFlow, 'subFlowType', 'fName'),
@@ -479,7 +588,43 @@ function formatSubFlowStep(stepNum, pad, subFlow) {
479
588
  lines.push(`${pad}${stepNum}. ↪ ${subFlowName}`);
480
589
  }
481
590
 
482
- lines.push(`${pad} jsn flows "${subFlowName}"`);
591
+ // Recurse into the subflow when depth allows and we haven't already seen it.
592
+ // Note: `subflowSysId` on the instance is a snapshot id (sys_hub_flow_snapshot),
593
+ // NOT the flow id. The embedded `subFlow.parentFlow` is the real flow sys_id.
594
+ const subflowSysId = firstNonEmpty(
595
+ getNestedString(subFlow, 'subFlow', 'parentFlow'),
596
+ getStringField(subFlow, 'subflowSysId'),
597
+ getNestedString(subFlow, 'subFlow', 'sys_id'),
598
+ getStringField(subFlow, 'sys_id'),
599
+ );
600
+ const depth = ctx?.depth ?? 1;
601
+ const canRecurse = ctx?.sdk && subflowSysId && depth > 1 && !ctx.visited.has(subflowSysId);
602
+
603
+ if (!canRecurse) {
604
+ if (!subflowSysId) {
605
+ lines.push(`${pad} (subflow definition not found)`);
606
+ } else if (depth <= 1) {
607
+ lines.push(`${pad} jsn flows show "${subFlowName}"`);
608
+ }
609
+ return lines;
610
+ }
611
+
612
+ ctx.visited.add(subflowSysId);
613
+ try {
614
+ const subInspection = await ctx.sdk.inspectFlow(subflowSysId);
615
+ const subCtx = { ...ctx, depth: depth - 1 };
616
+ const subLines = await formatFlowStructure(subInspection, subCtx);
617
+ if (subLines.length === 0) {
618
+ lines.push(`${pad} (no steps found)`);
619
+ } else {
620
+ const innerPad = pad + ' ';
621
+ for (const l of subLines) {
622
+ lines.push(innerPad + l.replace(/^ {2}/, ''));
623
+ }
624
+ }
625
+ } catch (e) {
626
+ lines.push(`${pad} (could not load subflow: ${e.message})`);
627
+ }
483
628
  return lines;
484
629
  }
485
630
 
package/src/sdk.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // ServiceNow REST API client
2
2
 
3
+ import { gunzipSync } from 'node:zlib';
3
4
  import { errAuth, errAPI, errNetwork } from './errors.js';
4
5
  import { getStringField } from './helpers.js';
5
6
 
@@ -244,30 +245,54 @@ export class SDKClient {
244
245
  subFlowInstances: [],
245
246
  flowInputs: [],
246
247
  flowOutputs: [],
248
+ flowVariables: [],
247
249
  };
248
250
 
249
- // 2) Fetch latest flow version
250
- const versionQuery = new URLSearchParams();
251
- versionQuery.set('sysparm_display_value', 'all');
252
- versionQuery.set('sysparm_limit', '1');
253
- versionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYDESCsys_updated_on`);
254
- versionQuery.set('sysparm_fields', 'sys_id,flow,version,payload,sys_updated_on');
255
-
256
- const versionRecords = await this.list('sys_hub_flow_version', versionQuery);
257
- if (versionRecords && versionRecords.length > 0) {
258
- inspection.version = versionRecords[0];
259
- if (!inspection.flow.version) {
260
- inspection.flow.version = getStringField(versionRecords[0], 'version');
251
+ // 2) Prefer the ProcessFlow API — the Flow Designer UI's own source.
252
+ // Returns complete structure for both V2 and legacy V1 flows (the
253
+ // version payload field is empty for V1 flows).
254
+ let payloadLoaded = false;
255
+ try {
256
+ const pfResponse = await this.request(`${this.baseURL}/api/now/processflow/flow/${flowSysID}`, { method: 'GET' });
257
+ const pfData = pfResponse?.result?.data;
258
+ if (pfData && typeof pfData === 'object' && !pfResponse?.result?.errorMessage) {
259
+ inspection.payload = pfData;
260
+ if (!inspection.flow.version) {
261
+ const pfVersion = getStringField(pfData, 'version');
262
+ if (pfVersion) inspection.flow.version = pfVersion;
263
+ }
264
+ hydrateFlowBlocks(inspection.payload);
265
+ extractPayloadData(inspection, inspection.payload);
266
+ payloadLoaded = true;
261
267
  }
268
+ } catch {
269
+ // ProcessFlow API may not exist on older instances — fall through
270
+ }
271
+
272
+ // 2b) Fallback: latest flow version payload
273
+ if (!payloadLoaded) {
274
+ const versionQuery = new URLSearchParams();
275
+ versionQuery.set('sysparm_display_value', 'all');
276
+ versionQuery.set('sysparm_limit', '1');
277
+ versionQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYDESCsys_updated_on`);
278
+ versionQuery.set('sysparm_fields', 'sys_id,flow,version,payload,sys_updated_on');
279
+
280
+ const versionRecords = await this.list('sys_hub_flow_version', versionQuery);
281
+ if (versionRecords && versionRecords.length > 0) {
282
+ inspection.version = versionRecords[0];
283
+ if (!inspection.flow.version) {
284
+ inspection.flow.version = getStringField(versionRecords[0], 'version');
285
+ }
262
286
 
263
- const payload = getStringField(versionRecords[0], 'payload');
264
- if (payload) {
265
- try {
266
- const payloadData = JSON.parse(payload);
267
- inspection.payload = payloadData;
268
- extractPayloadData(inspection, payloadData);
269
- } catch {
270
- // ignore parse error
287
+ const payload = getStringField(versionRecords[0], 'payload');
288
+ if (payload) {
289
+ try {
290
+ const payloadData = JSON.parse(payload);
291
+ inspection.payload = payloadData;
292
+ extractPayloadData(inspection, payloadData);
293
+ } catch {
294
+ // ignore parse error
295
+ }
271
296
  }
272
297
  }
273
298
  }
@@ -295,15 +320,24 @@ export class SDKClient {
295
320
  }
296
321
 
297
322
  if (!inspection.flowLogicInstances || inspection.flowLogicInstances.length === 0) {
298
- const logicTables = ['sys_hub_flow_logic', 'sys_hub_flow_logic_instance_v2'];
299
- for (const table of logicTables) {
323
+ const logicTables = [
324
+ { table: 'sys_hub_flow_logic', payloadField: 'inputs' },
325
+ { table: 'sys_hub_flow_logic_instance_v2', payloadField: 'values' },
326
+ ];
327
+ for (const { table, payloadField } of logicTables) {
300
328
  const logicQuery = new URLSearchParams();
301
329
  logicQuery.set('sysparm_display_value', 'all');
302
330
  logicQuery.set('sysparm_query', `flow=${flowSysID}^ORDERBYorder`);
303
- logicQuery.set('sysparm_fields', 'sys_id,order,name,display_text,comment,parent_ui_id,logic_definition');
331
+ logicQuery.set('sysparm_fields', `sys_id,order,name,display_text,comment,parent_ui_id,logic_definition,${payloadField}`);
304
332
  logicQuery.set('sysparm_limit', '200');
305
333
  const records = await this.list(table, logicQuery);
306
- if (records) inspection.flowLogicInstances.push(...records);
334
+ if (records) {
335
+ for (const rec of records) {
336
+ const decoded = decodeGzipJson(rec[payloadField]);
337
+ if (decoded) rec._decodedValues = decoded;
338
+ }
339
+ inspection.flowLogicInstances.push(...records);
340
+ }
307
341
  }
308
342
  inspection.flowLogicInstances.sort((a, b) => parseOrderField(a) - parseOrderField(b));
309
343
  }
@@ -339,6 +373,20 @@ export class SDKClient {
339
373
  if (records) inspection.flowOutputs = records;
340
374
  }
341
375
 
376
+ // Flow variables fallback (payload.flowVariables may be empty on legacy
377
+ // payload shapes, but sys_hub_flow_variable rows exist for every flow).
378
+ // Note: the table only carries name/label/order — type is empty there, so
379
+ // prefer payload.flowVariables when available.
380
+ if (!inspection.flowVariables || inspection.flowVariables.length === 0) {
381
+ const varsQuery = new URLSearchParams();
382
+ varsQuery.set('sysparm_display_value', 'all');
383
+ varsQuery.set('sysparm_query', `model=${flowSysID}^ORDERBYorder`);
384
+ varsQuery.set('sysparm_fields', 'sys_id,name,label,type,order');
385
+ varsQuery.set('sysparm_limit', '200');
386
+ const records = await this.list('sys_hub_flow_variable', varsQuery);
387
+ if (records) inspection.flowVariables = records;
388
+ }
389
+
342
390
  return inspection;
343
391
  }
344
392
 
@@ -602,6 +650,9 @@ function extractPayloadData(inspection, payload) {
602
650
  const outputs = toMapSlice(payload.outputs);
603
651
  if (outputs) inspection.flowOutputs = outputs;
604
652
 
653
+ const flowVariables = toMapSlice(payload.flowVariables);
654
+ if (flowVariables) inspection.flowVariables = flowVariables;
655
+
605
656
  const triggerInstances = payload.triggerInstances;
606
657
  if (!Array.isArray(triggerInstances) || triggerInstances.length === 0) return;
607
658
 
@@ -634,8 +685,72 @@ function toMapSlice(v) {
634
685
  return v.filter(item => item && typeof item === 'object');
635
686
  }
636
687
 
688
+ /**
689
+ * The ProcessFlow API returns flow structure as flat arrays where children
690
+ * reference their parent logic step via a `parent` / `parent_ui_id` field
691
+ * (a uiUniqueIdentifier value). The payload-based renderer expects nested
692
+ * `flowBlock` arrays on each logic instance. Rebuild that nesting here so
693
+ * both data sources render identically.
694
+ */
695
+ export function hydrateFlowBlocks(payload) {
696
+ const blockKeys = ['actionInstances', 'flowLogicInstances', 'subFlowInstances'];
697
+ const items = [];
698
+ for (const key of blockKeys) {
699
+ if (Array.isArray(payload[key])) {
700
+ for (const item of payload[key]) {
701
+ if (item && typeof item === 'object') items.push(item);
702
+ }
703
+ }
704
+ }
705
+ if (items.length === 0) return;
706
+
707
+ // If the payload already has nested flowBlock arrays (version-payload
708
+ // shape), leave it alone.
709
+ const logicItems = Array.isArray(payload.flowLogicInstances) ? payload.flowLogicInstances : [];
710
+ if (logicItems.some(l => l && typeof l === 'object' && Array.isArray(l.flowBlock))) return;
711
+
712
+ // Build parent → children map keyed by uiUniqueIdentifier
713
+ const childrenByParent = new Map();
714
+ for (const item of items) {
715
+ const parentId = getStringField(item, 'parent_ui_id') || getStringField(item, 'parent');
716
+ if (!parentId) continue;
717
+ if (!childrenByParent.has(parentId)) childrenByParent.set(parentId, []);
718
+ childrenByParent.get(parentId).push(item);
719
+ }
720
+
721
+ // Attach sorted flowBlock to each logic instance
722
+ for (const logic of logicItems) {
723
+ if (!logic || typeof logic !== 'object') continue;
724
+ const uid = getStringField(logic, 'uiUniqueIdentifier');
725
+ const children = uid ? childrenByParent.get(uid) : null;
726
+ if (Array.isArray(children) && children.length > 0) {
727
+ children.sort((a, b) => parseOrderField(a) - parseOrderField(b));
728
+ logic.flowBlock = children;
729
+ }
730
+ }
731
+ }
732
+
637
733
  function parseOrderField(record) {
638
734
  const order = getStringField(record, 'order');
639
735
  const n = parseInt(order, 10);
640
736
  return isNaN(n) ? 0 : n;
641
737
  }
738
+
739
+ /**
740
+ * Decode a ServiceNow flow-logic "values" field: base64-gzip JSON.
741
+ * Returns the parsed object, or null when the field is empty / not gzip / unparseable.
742
+ */
743
+ export function decodeGzipJson(value) {
744
+ if (!value) return null;
745
+ const s = (typeof value === 'object' && value !== null)
746
+ ? (value.value ?? value.display_value ?? '')
747
+ : String(value);
748
+ const trimmed = s.trim();
749
+ if (!trimmed.startsWith('H4sI')) return null; // not gzip base64
750
+ try {
751
+ const decoded = gunzipSync(Buffer.from(trimmed, 'base64')).toString('utf-8');
752
+ return JSON.parse(decoded);
753
+ } catch {
754
+ return null;
755
+ }
756
+ }