@jacebenson/jsn 4.3.7-dev.39 → 4.3.7-dev.40

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": "4.3.7-dev.39",
3
+ "version": "4.3.7-dev.40",
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": {
@@ -6,6 +6,7 @@ import { inspectFlow } from '../flow-inspection.js';
6
6
  import { publishFlows, publishFlowLifecycle, publishDoctor, flowStatus } from '../flow-publish.js';
7
7
  import { resolveRecord, unwrapSysId } from '../resolve-record.js';
8
8
  import { createFlowFromManifest, updateFlowFromManifest, readCurrentUpdateSet } from '../flow-create.js';
9
+ import { EXCLUDED_TRIGGER_TYPES, SUPPORTED_TRIGGER_TYPES } from '../flow-triggers.js';
9
10
  import { testFlow } from '../flow-test.js';
10
11
  import { errUsage } from '../errors.js';
11
12
  import { cancelled, pickDesignerRecord, guidedFlowCreate, guidedFlowEdit, guidedFlowTestRecord, hasDefinitionData, confirmDesignerMutation } from '../flow-designer-interactive.js';
@@ -32,6 +33,18 @@ function flowInspectionAdapter(app) {
32
33
  };
33
34
  }
34
35
 
36
+ // The picker is driven by the instance's own trigger definitions, but this CLI
37
+ // can only create the families it has verified. State both plainly so a created
38
+ // flow never surprises the operator with a trigger that was not really set.
39
+ function triggerGuidance(trigger) {
40
+ return {
41
+ supported_families: [...SUPPORTED_TRIGGER_TYPES].sort(),
42
+ excluded_families: [...EXCLUDED_TRIGGER_TYPES.entries()].map(([type, reason]) => ({ type, reason })).sort((a, b) => a.type.localeCompare(b.type)),
43
+ created_trigger: trigger ? { id: trigger.id, type: trigger.type, name: trigger.name, triggerDefinitionId: trigger.triggerDefinitionId } : null,
44
+ note: 'The trigger picker lists only families this CLI can create and verify. Families the picker omits are reported in excluded_families.',
45
+ };
46
+ }
47
+
35
48
  declareCapabilities('flows', { mutationSubcommands: ['create', 'update', 'edit', 'delete', 'publish', 'test'] });
36
49
 
37
50
  function renderChecks(checks) {
@@ -281,10 +294,11 @@ export function flowsCmd(wrap) {
281
294
  })
282
295
  .command({
283
296
  command: 'create',
284
- describe: 'Guide draft flow creation in a terminal, or use a schema-driven JSON manifest',
297
+ describe: 'Create a draft flow: pick a trigger category then trigger in a terminal, or use a JSON manifest in scripts',
285
298
  builder: (y) => y
286
299
  .option('data', { type: 'string', describe: 'Flow manifest as inline JSON' })
287
- .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' }),
300
+ .option('data-file', { type: 'string', describe: 'Read the flow manifest from a JSON file' })
301
+ .option('scope', { type: 'string', describe: 'Transaction scope sys_id or global for the guided picker' }),
288
302
  handler: wrap(async (argv, app) => {
289
303
  app.requireInstance();
290
304
  const dataFile = argv.dataFile ?? argv['data-file'];
@@ -299,8 +313,8 @@ export function flowsCmd(wrap) {
299
313
  const result = await createFlowFromManifest(app.sdk, app.getEffectiveInstance(), manifest, {
300
314
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
301
315
  });
302
- app.ok(result, {
303
- summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
316
+ app.ok({ ...result, trigger_guidance: triggerGuidance(result.trigger) }, {
317
+ summary: `Created draft flow "${result.flow.name}" (${result.flow.id}) with trigger ${result.trigger?.type ?? 'unknown'} (${result.trigger?.id ?? 'no trigger id'}) without activation`,
304
318
  breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the draft flow' }],
305
319
  });
306
320
  }),
@@ -1,10 +1,20 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assertSafeExactMatch } from './helpers.js';
3
3
  import { createVerifiedFlowVersion } from './flow-versioning.js';
4
+ import {
5
+ SUPPORTED_TRIGGER_TYPES,
6
+ TRIGGER_DEFINITION_TABLE,
7
+ buildTriggerInputs,
8
+ buildTriggerInstance,
9
+ conditionInputFor,
10
+ readTableDescriptor,
11
+ readTriggerInputDefinitions,
12
+ tableInputFor,
13
+ flowDesignParameter,
14
+ } from './flow-triggers.js';
4
15
 
5
16
  const FLOW_ENDPOINT = '/api/now/processflow/flow';
6
17
  const GRAPHQL_ENDPOINT = '/api/now/graphql';
7
- const TABLE_DESCRIPTOR_ENDPOINT = '/api/now/processflow/trigger/record/table';
8
18
  const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
9
19
  const PROTECTED_FLOW_NAME = 'jace-test-flow';
10
20
 
@@ -132,12 +142,15 @@ export function validateFlowManifest(manifest) {
132
142
  if (manifest.variables !== undefined || manifest.flowVariables !== undefined) throw new Error('Flow-variable lifecycle is unsupported in this creation slice');
133
143
  if (manifest.errorHandling !== undefined) throw new Error('Error handling is unsupported in this creation slice');
134
144
  const trigger = manifest.trigger;
135
- if (!trigger || trigger.type !== 'record_create_or_update') throw new Error('Only trigger.type=record_create_or_update is supported');
136
- if (!String(trigger.table || '').trim()) throw new Error('Record trigger requires table');
145
+ if (!trigger || typeof trigger !== 'object' || Array.isArray(trigger)) throw new Error('Flow manifest requires a trigger object');
146
+ for (const key of Object.keys(trigger)) if (!['type', 'table', 'condition', 'inputs'].includes(key)) throw new Error(`Flow manifest trigger field "${key}" is unsupported`);
147
+ if (!SUPPORTED_TRIGGER_TYPES.has(String(trigger.type))) throw new Error(`Unsupported trigger.type "${trigger.type}"; supported families are ${[...SUPPORTED_TRIGGER_TYPES].join(', ')}`);
148
+ if (trigger.table !== undefined && !String(trigger.table).trim()) throw new Error('Trigger table must not be empty');
137
149
  if (trigger.condition !== undefined && typeof trigger.condition !== 'string') throw new Error('Record trigger condition must be a string');
138
150
  if (trigger.condition && !/^[A-Za-z_][A-Za-z0-9_.]*(?:STARTSWITH|ENDSWITH|LIKE|NOT LIKE|=|!=|IN|ISEMPTY|ISNOTEMPTY)[^,^]*$/.test(trigger.condition)) {
139
151
  throw new Error('Record trigger condition uses an unsupported encoded-query condition subset');
140
152
  }
153
+ if (trigger.inputs !== undefined && (!trigger.inputs || typeof trigger.inputs !== 'object' || Array.isArray(trigger.inputs))) throw new Error('Trigger inputs must be an object');
141
154
  if (!Array.isArray(manifest.actions)) throw new Error('Flow manifest requires an actions array');
142
155
  for (const [index, action] of manifest.actions.entries()) {
143
156
  if (!action || typeof action !== 'object' || !String(action.type || '').trim()) throw new Error(`Action ${index + 1} requires type`);
@@ -165,8 +178,12 @@ export function buildFlowProperties(manifest) {
165
178
  }
166
179
 
167
180
  export function buildFlowPatchMutation({ flowId, trigger, actions }) {
168
- const triggerUpdate = {
169
- id: trigger.id,
181
+ const triggerInsert = {
182
+ flowSysId: trigger.flowSysId,
183
+ name: trigger.name,
184
+ type: trigger.type,
185
+ triggerType: trigger.triggerType,
186
+ triggerDefinitionId: trigger.triggerDefinitionId,
170
187
  metadata: trigger.metadata,
171
188
  inputs: trigger.inputs,
172
189
  };
@@ -182,8 +199,10 @@ export function buildFlowPatchMutation({ flowId, trigger, actions }) {
182
199
  metadata: action.metadata,
183
200
  inputs: action.inputs,
184
201
  }));
185
- const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {update: [${gqlInputObject(triggerUpdate)}]}, actions: {insert: [${actionInsert.map(gqlInputObject).join(', ')}]}}`;
186
- return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { inserts { sysId uiUniqueIdentifier } updates { sysId uiUniqueIdentifier } } actions { inserts { sysId uiUniqueIdentifier } updates { sysId uiUniqueIdentifier } } } } } }`;
202
+ const patch = `flowPatch: {flowId: ${gqlString(flowId)}, triggerInstances: {insert: [${gqlInputObject(triggerInsert)}]}, actions: {insert: [${actionInsert.map(gqlInputObject).join(', ')}]}}`;
203
+ // `triggerInstances.updates` is a leaf [String] on this schema; only `inserts`
204
+ // carries identities, and only for triggers does `inserts` lack a uiUniqueIdentifier.
205
+ return `mutation { global { snFlowDesigner { flow(${patch}) { triggerInstances { inserts { sysId } updates } actions { inserts { sysId uiUniqueIdentifier } } } } } }`;
187
206
  }
188
207
 
189
208
  export function buildFlowUpdateMutation({ flowId, triggerUpdates = [], actionUpdates = [] }) {
@@ -236,8 +255,8 @@ export function buildTypedInputs(action, schema) {
236
255
  for (const key of Object.keys(supplied)) if (!allowed.has(key)) throw new Error(`Unsupported input "${key}" for action ${action.type}`);
237
256
  return schema.map(field => {
238
257
  // The instance's own definition is the contract: identity is required,
239
- // everything else (including an optional `parameter` block) is echoed back
240
- // untouched. Demanding a key the provider omits breaks real actions.
258
+ // everything else is echoed back untouched. Demanding a key the provider
259
+ // omits breaks real actions.
241
260
  if (!field || typeof field !== 'object' || !field.id || !field.name) {
242
261
  throw new Error(`Action ${action.type} returned an incomplete input schema; refusing to invent input metadata for "${field?.name || 'unknown'}"`);
243
262
  }
@@ -248,9 +267,17 @@ export function buildTypedInputs(action, schema) {
248
267
  if ((suppliedValue === undefined || suppliedValue === null) && (field.mandatory === true || field.required === true)) throw new Error(`Missing required input "${field.name}" for action ${action.type}`);
249
268
  const value = suppliedValue ?? null;
250
269
  const displayValue = field.displayValue ?? field.display_value ?? value;
270
+ // global_snFlowDesigner_ActionInstanceValue accepts ONLY these keys. The
271
+ // ProcessFlow action definition hands back the whole UI schema on the input
272
+ // object (uiDisplayTypeLabel, choices, type_label, …) and spreading it makes
273
+ // the mutation fail validation, so the schema is carried inside `parameter`.
251
274
  return {
252
- ...field,
275
+ id: field.id,
253
276
  name: field.name,
277
+ children: Array.isArray(field.children) ? field.children : [],
278
+ displayField: stringValue(field.displayField || field.display_field || ''),
279
+ scriptActive: false,
280
+ parameter: flowDesignParameter(field),
254
281
  value: { schemaless: false, schemalessValue: '', value },
255
282
  displayValue: { schemaless: false, schemalessValue: '', value: displayValue },
256
283
  };
@@ -264,51 +291,15 @@ function parseSimpleCondition(condition) {
264
291
  return { field: match[1], operator: match[2], term: match[3] };
265
292
  }
266
293
 
267
- export async function readTableDescriptor(sdk, instance, scope, table, override) {
268
- if (override) return override(table);
269
- const endpoint = `${instance}${TABLE_DESCRIPTOR_ENDPOINT}?table=${encodeURIComponent(table)}&sysparm_transaction_scope=${encodeURIComponent(scope)}`;
270
- const response = await sdk.request(endpoint, { method: 'GET' });
271
- const descriptor = response?.result ?? response?.data?.result ?? response?.data ?? response;
272
- if (!descriptor || typeof descriptor !== 'object') throw new Error(`ProcessFlow table descriptor was unavailable for ${table}`);
273
- const label = stringValue(descriptor.label || descriptor.displayValue || descriptor.display_value || descriptor.name);
274
- const displayField = stringValue(descriptor.displayField || descriptor.display_field || descriptor.displayColumn || descriptor.display_column);
275
- if (!label || !displayField) throw new Error(`ProcessFlow table descriptor for ${table} lacks label/display field`);
276
- return { ...descriptor, label, displayField };
277
- }
278
-
279
- function buildPredicateMetadata(condition, descriptor) {
280
- const predicate = parseSimpleCondition(condition);
281
- if (!predicate) return { predicates: [], order_by: [], group_by: [], has_rlq_conditions: false };
282
- const field = descriptor.fields?.find(item => stringValue(item.name || item.element || item.field) === predicate.field)
283
- || descriptor.schema?.find(item => stringValue(item.name || item.element || item.field) === predicate.field);
284
- if (!field) throw new Error(`Record trigger condition field "${predicate.field}" is absent from the table descriptor`);
285
- const fieldLabel = stringValue(field.label || field.column_label || predicate.field);
286
- const operatorLabel = stringValue(field?.operators?.[predicate.operator] || predicate.operator);
287
- const comparison = {
288
- field_type: stringValue(field?.field_type || field?.type || 'string'),
289
- operator: predicate.operator,
290
- term_label: predicate.term,
291
- or_query: false,
292
- field_label: fieldLabel,
293
- column_type: stringValue(field?.column_type || 'element'),
294
- term: predicate.term,
295
- display_value: '',
296
- new_query: false,
297
- goto_query: false,
298
- is_pre_evaluated: false,
299
- rlqc_query: false,
300
- operator_label: operatorLabel,
301
- value: predicate.term,
302
- field: predicate.field,
303
- type: 'comparison',
304
- };
305
- return {
306
- predicates: [{ compound_type: 'or', subpredicates: [{ compound_type: 'and', subpredicates: [{ compound_type: 'and', subpredicates: [comparison], type: 'compound' }], type: 'compound' }], type: 'compound' }],
307
- order_by: [],
308
- group_by: [],
309
- has_rlq_conditions: false,
310
- };
311
- }
294
+ // Trigger predicate metadata is DERIVED BY THE INSTANCE, not by this CLI: the
295
+ // snFlowDesigner patch parses the `condition` input into
296
+ // `metadata.query_parse.predicates` itself (verified live on insert and on
297
+ // update — the returned field_label/operator_label/field_type match Flow
298
+ // Designer's own output). Sending `{}` and verifying the derived predicate by
299
+ // read-back is therefore both simpler and more faithful than rebuilding the
300
+ // predicate tree from a table descriptor, and it removes the dependency on the
301
+ // ProcessFlow table-descriptor route, which returns 400 on this instance.
302
+ const DEFAULT_TRIGGER_METADATA = '{}';
312
303
 
313
304
  function predicateMetadataMatches(metadata, condition) {
314
305
  const expected = parseSimpleCondition(condition);
@@ -317,10 +308,15 @@ function predicateMetadataMatches(metadata, condition) {
317
308
  const comparisons = [];
318
309
  walk(parsed, object => {
319
310
  if (stringValue(object.type).toLowerCase() !== 'comparison') return;
311
+ // The instance stores the parsed predicate with `term` holding the WHOLE
312
+ // encoded condition and `value` holding the extracted term, so the term
313
+ // comparison must prefer `value` (e.g. for
314
+ // "short_descriptionSTARTSWITHjacetest": term = the full string,
315
+ // value = "jacetest").
320
316
  comparisons.push({
321
317
  field: stringValue(object.field),
322
318
  operator: stringValue(object.operator),
323
- term: stringValue(object.term ?? object.value),
319
+ term: stringValue(object.value ?? object.term),
324
320
  });
325
321
  });
326
322
  return comparisons.some(comparison => comparison.field === expected.field
@@ -332,18 +328,39 @@ function assertPredicateMetadataMatches(metadata, condition, label) {
332
328
  if (!predicateMetadataMatches(metadata, condition)) throw new Error(`${label} predicate metadata did not match the requested field, operator, and term`);
333
329
  }
334
330
 
331
+ // The instance stores predicate metadata as {query_parse: {predicates: [...]}},
332
+ // so dropping only a top-level `predicates` key is not enough to compare the
333
+ // parts of the metadata a manifest did not ask to change.
334
+ function stripPredicates(value) {
335
+ if (Array.isArray(value)) return value.map(stripPredicates);
336
+ if (!value || typeof value !== 'object') return value;
337
+ const out = {};
338
+ for (const [key, item] of Object.entries(value)) {
339
+ if (key === 'predicates') continue;
340
+ out[key] = stripPredicates(item);
341
+ }
342
+ return out;
343
+ }
344
+
335
345
  function actionTypeIdOf(action) {
336
346
  return idValue(action?.actionTypeSysId || action?.action_type_sys_id || action?.actionType?.sys_id || action?.actionType?.sysId);
337
347
  }
338
348
 
339
- function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted }) {
349
+ function verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted }) {
340
350
  const returnedFlowId = stringValue(processflow.flowId || processflow.flow_id || processflow.sysId || processflow.sys_id);
341
351
  if (returnedFlowId && returnedFlowId !== flowId) throw new Error(`ProcessFlow read-back returned a different flow ID (${returnedFlowId})`);
342
352
 
343
353
  const triggers = findArray(processflow, 'triggerInstances');
344
354
  if (triggers.length !== 1) throw new Error(`ProcessFlow read-back returned ${triggers.length} trigger instances; expected one`);
345
355
  const savedTrigger = triggers[0];
346
- if (findId(savedTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== trigger.id) throw new Error('Created trigger identity was not preserved in ProcessFlow read-back');
356
+ const savedTriggerId = findId(savedTrigger, ['sysId', 'sys_id', 'id', 'triggerId']);
357
+ const insertedTriggerId = findId(insertedTrigger || {}, ['sysId', 'sys_id', 'id']);
358
+ if (!insertedTriggerId) throw new Error('Flow trigger insert did not return a trigger identity');
359
+ if (savedTriggerId !== insertedTriggerId) throw new Error('Created trigger identity was not preserved in ProcessFlow read-back');
360
+ if (stringValue(savedTrigger.triggerDefinitionId || savedTrigger.trigger_definition_id) !== trigger.triggerDefinitionId) {
361
+ throw new Error(`Created trigger definition did not match the requested ${trigger.triggerDefinitionId}`);
362
+ }
363
+ if (stringValue(savedTrigger.type) !== trigger.type) throw new Error(`Created trigger type did not match the requested ${trigger.type}`);
347
364
  const savedTriggerInputs = inputList(savedTrigger);
348
365
  for (const expected of trigger.inputs) {
349
366
  const actual = savedTriggerInputs.find(input => input.name === expected.name);
@@ -374,23 +391,70 @@ function verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, act
374
391
  }
375
392
  }
376
393
 
377
- function makeTriggerPatch(flow, manifest, descriptor) {
378
- const trigger = findArray(flow, 'triggerInstances')[0];
379
- if (!trigger) throw new Error('Created flow did not return its default record trigger instance');
380
- const id = findId(trigger, ['sysId', 'sys_id', 'id', 'triggerId']);
381
- if (!id) throw new Error('Created flow trigger has no usable identity');
382
- const metadata = buildPredicateMetadata(manifest.trigger.condition || '', descriptor);
383
- const wrapped = value => ({ schemaless: false, schemalessValue: '', value });
394
+ export async function resolveTriggerDefinition(sdk, type) {
395
+ if (!SUPPORTED_TRIGGER_TYPES.has(String(type))) throw new Error(`Unsupported trigger.type "${type}"`);
396
+ const rows = await sdk.list(TRIGGER_DEFINITION_TABLE, new URLSearchParams({
397
+ sysparm_query: `type=${type}^active=true`,
398
+ sysparm_limit: '5',
399
+ sysparm_display_value: 'all',
400
+ sysparm_fields: 'sys_id,name,type,active,category',
401
+ }));
402
+ if (rows.length !== 1) throw new Error(rows.length === 0
403
+ ? `Trigger definition not found or inactive: ${type}`
404
+ : `Trigger definition is ambiguous: ${type}`);
405
+ const row = rows[0];
384
406
  return {
385
- id,
386
- metadata: JSON.stringify(metadata),
387
- inputs: [
388
- { name: 'table', displayField: descriptor.displayField, displayValue: wrapped(descriptor.label), value: wrapped(manifest.trigger.table) },
389
- { name: 'condition', displayField: manifest.trigger.condition || '', displayValue: wrapped(manifest.trigger.condition || ''), value: wrapped(manifest.trigger.condition || '') },
390
- ],
407
+ sysId: idValue(row.sys_id || row.sysId),
408
+ name: stringValue(row.name),
409
+ type: stringValue(row.type),
410
+ // The definition's category display name is what the trigger instance stores
411
+ // as triggerType ("Record", "Scheduled", "Application"). The raw value is the
412
+ // category sys_id, so take the display value when the API returned one.
413
+ triggerType: categoryDisplay(row.category),
391
414
  };
392
415
  }
393
416
 
417
+ function categoryDisplay(value) {
418
+ if (value && typeof value === 'object') return stringValue(value.display_value ?? value.value);
419
+ return stringValue(value);
420
+ }
421
+
422
+ /**
423
+ * Build the triggerInstances.insert payload from the definition's own declared
424
+ * inputs. `manifest.trigger.table` / `.condition` are shortcuts onto whichever
425
+ * declared input carries them; everything else goes through `trigger.inputs`.
426
+ */
427
+ export async function buildCreateTrigger(sdk, flowId, manifest, definition, options = {}) {
428
+ const declared = await readTriggerInputDefinitions(sdk, definition.sysId);
429
+ if (!declared.length && options.requireInputs === true) throw new Error(`Trigger definition ${definition.type} declares no inputs`);
430
+ const tableInput = tableInputFor(declared);
431
+ const conditionInput = conditionInputFor(declared);
432
+ const supplied = { ...(manifest.trigger.inputs || {}) };
433
+
434
+ if (manifest.trigger.table !== undefined) {
435
+ if (!tableInput) throw new Error(`Trigger ${definition.type} does not declare a table input`);
436
+ supplied[tableInput.name] = manifest.trigger.table;
437
+ }
438
+ if (manifest.trigger.condition !== undefined) {
439
+ if (!conditionInput) throw new Error(`Trigger ${definition.type} does not declare a condition input`);
440
+ supplied[conditionInput.name] = manifest.trigger.condition;
441
+ }
442
+ let descriptor = null;
443
+ if (tableInput && supplied[tableInput.name]) {
444
+ descriptor = options.readTableDescriptor
445
+ ? await options.readTableDescriptor(supplied[tableInput.name])
446
+ : await readTableDescriptor(sdk, supplied[tableInput.name]);
447
+ }
448
+
449
+ return buildTriggerInstance({
450
+ flowId,
451
+ definition,
452
+ triggerType: definition.triggerType,
453
+ metadata: DEFAULT_TRIGGER_METADATA,
454
+ inputs: buildTriggerInputs(declared, supplied, descriptor),
455
+ });
456
+ }
457
+
394
458
  function clone(value) {
395
459
  return value == null ? value : JSON.parse(JSON.stringify(value));
396
460
  }
@@ -511,9 +575,10 @@ function updatedTrigger(flow, manifest, descriptor) {
511
575
  if (!id) throw new Error('Saved record trigger has no usable identity');
512
576
  const update = { id, inputs };
513
577
  if (manifest.trigger.condition !== undefined) {
514
- const generated = buildPredicateMetadata(condition, descriptor);
515
- const metadata = { ...generated, ...(existingMetadata || {}), predicates: generated.predicates };
516
- update.metadata = JSON.stringify(metadata);
578
+ // The instance re-derives predicate metadata from the condition input; see
579
+ // DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
580
+ // derived predicate matches what was asked for.
581
+ update.metadata = DEFAULT_TRIGGER_METADATA;
517
582
  } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
518
583
  update.metadata = existing.metadata;
519
584
  }
@@ -528,6 +593,14 @@ function insertedActions(response) {
528
593
  return found;
529
594
  }
530
595
 
596
+ function insertedTriggers(response) {
597
+ let found = [];
598
+ walk(response, object => {
599
+ if (!found.length && object.triggerInstances?.inserts) found = object.triggerInstances.inserts;
600
+ });
601
+ return found;
602
+ }
603
+
531
604
  async function reconcileAmbiguousCreate(sdk, manifest) {
532
605
  const params = new URLSearchParams({ sysparm_query: `name=${manifest.name}`, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,active,status,scope' });
533
606
  const records = await sdk.list('sys_hub_flow', params);
@@ -609,9 +682,12 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
609
682
  parentSnapshot.capture_before = unavailableCapture('flow ID was not known before parent creation');
610
683
  await addPostMutationCapture(parentSnapshot, sdk, readUpdateSet, flowId);
611
684
  }
612
- const initialDefinition = await readProcessFlow(sdk, instance, flowId, scope);
613
- const tableDescriptor = await readTableDescriptor(sdk, instance, scope, manifest.trigger.table, options.readTableDescriptor);
614
- const trigger = makeTriggerPatch(initialDefinition, manifest, tableDescriptor);
685
+ // A newly POSTed ProcessFlow has NO trigger (verified live: triggerInstances
686
+ // comes back empty), so the trigger is inserted by the same snFlowDesigner
687
+ // patch as the actions. There is nothing to read back from the parent apart
688
+ // from the flow identity.
689
+ const definition = await resolveTriggerDefinition(sdk, manifest.trigger.type);
690
+ const trigger = await buildCreateTrigger(sdk, flowId, manifest, definition, options);
615
691
 
616
692
  const actionInstances = [];
617
693
  for (const [index, action] of manifest.actions.entries()) {
@@ -636,11 +712,13 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
636
712
  body: JSON.stringify({ query: buildFlowPatchMutation({ flowId, trigger, actions: actionInstances }), variables: {} }),
637
713
  }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
638
714
  assertGraphQLSuccess(patchResponse, 'Flow trigger/action patch');
715
+ const insertedTrigger = insertedTriggers(patchResponse)[0];
716
+ if (!insertedTrigger) throw new Error('Flow trigger insert returned no trigger identity');
639
717
  const inserted = insertedActions(patchResponse);
640
718
  if (inserted.length !== actionInstances.length) throw new Error(`Flow action patch returned ${inserted.length} inserted action identities; expected ${actionInstances.length}`);
641
719
 
642
720
  const processflow = await readProcessFlow(sdk, instance, flowId, scope);
643
- verifyCreatedFlowReadback({ flowId, processflow, trigger, manifest, actionInstances, inserted });
721
+ verifyCreatedFlowReadback({ flowId, processflow, trigger, insertedTrigger, manifest, actionInstances, inserted });
644
722
  const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
645
723
  instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow create',
646
724
  }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
@@ -650,7 +728,7 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
650
728
  const triggerReadback = findArray(processflow, 'triggerInstances')[0] || {};
651
729
  return {
652
730
  flow: { id: flowId, name: manifest.name, status: 'draft', active: false },
653
- trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) || trigger.id },
731
+ trigger: { ...triggerReadback, id: findId(triggerReadback, ['sysId', 'sys_id', 'id']) || findId(insertedTrigger, ['sysId', 'sys_id', 'id']) },
654
732
  actions: inserted,
655
733
  processflow,
656
734
  definition_readback: { available: true, status: 'read_back', flow_id: flowId },
@@ -660,7 +738,10 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
660
738
  lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
661
739
  };
662
740
  } catch (error) {
663
- throw new Error(`Flow ${flowId} creation incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
741
+ // Creation is two-stage: the parent POST leaves a real draft behind when the
742
+ // trigger/action patch fails. Name that orphan explicitly instead of leaving
743
+ // the caller to hunt for it.
744
+ throw new Error(`Flow ${flowId} creation incomplete and left orphan draft flow ${flowId} (delete it in Flow Designer): ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
664
745
  }
665
746
  }
666
747
 
@@ -682,7 +763,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
682
763
  if (manifest.trigger !== undefined) {
683
764
  const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
684
765
  const currentTable = inputRawValue(inputList(currentTrigger).find(input => input.name === 'table'));
685
- const descriptor = await readTableDescriptor(sdk, instance, scope, manifest.trigger.table ?? currentTable, options.readTableDescriptor);
766
+ const descriptor = options.readTableDescriptor
767
+ ? await options.readTableDescriptor(manifest.trigger.table ?? currentTable)
768
+ : await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
686
769
  triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
687
770
  triggerUpdates.push(triggerUpdate);
688
771
  }
@@ -726,10 +809,8 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
726
809
  if (manifest.trigger.condition !== undefined) {
727
810
  if (inputRawValue(readbackInputs.find(input => input.name === 'condition')) !== manifest.trigger.condition) throw new Error(`Flow ${flowId} trigger condition readback did not match the requested value`);
728
811
  assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
729
- const beforeMetadata = parseMaybeJson(beforeTrigger.metadata, {});
730
- const afterMetadata = parseMaybeJson(readbackTrigger.metadata, {});
731
- delete beforeMetadata.predicates;
732
- delete afterMetadata.predicates;
812
+ const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
813
+ const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
733
814
  if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
734
815
  } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
735
816
  throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
@@ -7,7 +7,18 @@ import { FormatAuto } from './output.js';
7
7
  import { errUsage, errConfirmationRequired } from './errors.js';
8
8
  import { getCurrentUser, getCurrentApplication } from './context.js';
9
9
  import { validateDefinition, validateScope, ACTION_TABLE } from './processflow-actions.js';
10
- import { validateFlowManifest, validateFlowUpdateManifest, readProcessFlow, readActionDefinition, readTableDescriptor } from './flow-create.js';
10
+ import { validateFlowManifest, validateFlowUpdateManifest, readProcessFlow, readActionDefinition } from './flow-create.js';
11
+ import {
12
+ NUMERIC_TRIGGER_TYPES,
13
+ PROMPTABLE_TRIGGER_TYPES,
14
+ conditionInputFor,
15
+ readTableDescriptor,
16
+ readTriggerCatalog,
17
+ readTriggerInputDefinitions,
18
+ selectableTriggers,
19
+ tableInputFor,
20
+ triggerChoiceLabel,
21
+ } from './flow-triggers.js';
11
22
  import { deriveRecordTrigger } from './flow-test.js';
12
23
  import { getActiveProfile } from './config.js';
13
24
 
@@ -253,12 +264,91 @@ async function tableName(argv, app) {
253
264
  if (!name) throw errUsage('Selected trigger table has no raw name');
254
265
  return name;
255
266
  }
267
+
268
+ // Prompt for one declared trigger input. Choice lists are not exposed by
269
+ // sys_hub_trigger_input on this instance, and every supported family declares
270
+ // its choice/boolean inputs as optional with a default, so those keep the
271
+ // instance default instead of inventing options.
272
+ async function promptTriggerInput(app, input) {
273
+ if (!input?.name || !input.type) throw errUsage('Provider returned an incomplete named, typed trigger input');
274
+ if (input.type === 'choice') {
275
+ if (input.mandatory) throw errUsage(`Trigger input ${input.name} is a mandatory choice whose options this instance does not expose; supply it through --data-file`);
276
+ return null;
277
+ }
278
+ if (input.type === 'boolean') return null;
279
+ if (!PROMPTABLE_TRIGGER_TYPES.has(input.type)) {
280
+ throw errUsage(`Trigger input ${input.name} has unsupported type ${input.type}; use --data-file to preserve its complete schema and value`);
281
+ }
282
+ const p = prompts(app);
283
+ const message = `Trigger input: ${input.label || input.name} [${input.type}]`;
284
+ const fallback = input.defaultValue === '' || input.defaultValue === undefined ? '' : String(input.defaultValue);
285
+ if (NUMERIC_TRIGGER_TYPES.has(input.type)) {
286
+ const answer = await p.input({ message, default: scalarDefault(fallback), validate: value => {
287
+ if (value === '' && input.mandatory) return 'A value is required';
288
+ if (value === '') return true;
289
+ return Number.isFinite(Number(value)) ? true : 'Enter a number';
290
+ } });
291
+ return answer === '' ? '' : Number(answer);
292
+ }
293
+ return p.input({ message, default: scalarDefault(fallback), validate: value => (input.mandatory && value === '' ? 'A value is required' : true) });
294
+ }
295
+
256
296
  export async function guidedFlowCreate(argv, app) {
257
297
  if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the flow manifest in noninteractive mode');
258
- const manifest = { name: await prompts(app).input({ message: 'Flow name', validate: required }),
259
- description: await prompts(app).input({ message: 'Description' }), scope: await designerScope(argv, app),
260
- trigger: { type: 'record_create_or_update', table: await tableName(argv, app),
261
- condition: await prompts(app).input({ message: 'Trigger condition (single supported encoded predicate, or empty)' }) }, actions: [] };
298
+ const name = await prompts(app).input({ message: 'Flow name', validate: required });
299
+ const description = await prompts(app).input({ message: 'Description' });
300
+ const scope = await designerScope(argv, app);
301
+ const manifest = { name, description, scope, trigger: { type: '' }, actions: [] };
302
+
303
+ // Category first, then the triggers that category actually contains, both
304
+ // read from the instance (sys_hub_trigger_category + the definition's own
305
+ // `category` reference). Definitions this CLI cannot create are never offered.
306
+ const catalog = await readTriggerCatalog(app.sdk);
307
+ const pickable = catalog.categories.filter(category => selectableTriggers(category).length);
308
+ if (!pickable.length) {
309
+ throw errUsage('No supported trigger categories are available on this instance; use --data-file to supply the manifest directly');
310
+ }
311
+ const chosenCategory = await prompts(app).select({
312
+ message: 'Select a trigger category',
313
+ choices: pickable.map(category => ({ name: `${category.name} (${selectableTriggers(category).length})`, value: category })),
314
+ });
315
+ const triggers = selectableTriggers(chosenCategory);
316
+ const definition = await prompts(app).select({
317
+ message: `Select a ${chosenCategory.name} trigger`,
318
+ choices: triggers.map(trigger => ({ name: triggerChoiceLabel(trigger), value: trigger })),
319
+ });
320
+ manifest.trigger.type = definition.type;
321
+
322
+ const declared = await readTriggerInputDefinitions(app.sdk, definition.sysId);
323
+ const tableInput = tableInputFor(declared);
324
+ if (tableInput) {
325
+ manifest.trigger.table = await tableName(argv, app);
326
+ }
327
+ const conditionInput = conditionInputFor(declared);
328
+ if (conditionInput) {
329
+ manifest.trigger.condition = await prompts(app).input({ message: 'Trigger condition (single supported encoded predicate, or empty)' });
330
+ }
331
+ const optional = declared.filter(input => !tableInput || input.name !== tableInput.name)
332
+ .filter(input => !conditionInput || input.name !== conditionInput.name);
333
+ if (optional.some(input => input.mandatory)) {
334
+ manifest.trigger.inputs = {};
335
+ for (const input of optional.filter(candidate => candidate.mandatory)) {
336
+ const value = await promptTriggerInput(app, input);
337
+ if (value !== null) manifest.trigger.inputs[input.name] = value;
338
+ }
339
+ }
340
+ if (optional.some(input => !input.mandatory && input.type !== 'choice' && input.type !== 'boolean')
341
+ && await prompts(app).confirm({ message: 'Change optional trigger inputs (otherwise the instance defaults are used)?', default: false })) {
342
+ manifest.trigger.inputs = manifest.trigger.inputs || {};
343
+ for (const input of optional.filter(candidate => !candidate.mandatory)) {
344
+ if (input.type === 'choice' || input.type === 'boolean') continue;
345
+ if (!await prompts(app).confirm({ message: `Change ${input.label || input.name} [${input.type}]?`, default: false })) continue;
346
+ const value = await promptTriggerInput(app, input);
347
+ if (value !== null) manifest.trigger.inputs[input.name] = value;
348
+ }
349
+ }
350
+ if (manifest.trigger.inputs && !Object.keys(manifest.trigger.inputs).length) delete manifest.trigger.inputs;
351
+
262
352
  while (await prompts(app).confirm({ message: 'Add a published action?', default: true })) {
263
353
  const id = await pickDesignerRecord(argv, app, ACTION_TABLE, 'published action', 'active=true^published=true');
264
354
  if (id === undefined) throw CANCEL;
@@ -273,7 +363,7 @@ export async function guidedFlowCreate(argv, app) {
273
363
  manifest.actions.push({ type: id, inputs });
274
364
  }
275
365
  validateFlowManifest(manifest);
276
- await confirmDesignerSave(app, `Create inactive draft flow '${manifest.name}' with ${manifest.actions.length} action(s)? No publish or execution.`);
366
+ await confirmDesignerSave(app, `Create inactive draft flow '${manifest.name}' with trigger ${definition.name} and ${manifest.actions.length} action(s)? No publish or execution.`);
277
367
  return manifest;
278
368
  }
279
369
  export async function savedFlowDefinition(app, identifier) {
@@ -319,9 +409,9 @@ export async function guidedFlowEdit(argv, app) {
319
409
  export async function guidedFlowTestRecord(argv, app) {
320
410
  if (argv.record !== undefined) return argv.record;
321
411
  if (!interactiveDesigner(argv, app)) throw errUsage('Provide --record <sys_id> in noninteractive mode');
322
- const { record, definition } = await savedFlowDefinition(app, argv.identifier);
412
+ const { definition } = await savedFlowDefinition(app, argv.identifier);
323
413
  const { table } = deriveRecordTrigger(definition);
324
- const descriptor = await readTableDescriptor(app.sdk, app.getEffectiveInstance(), rawField(record.scope), table);
414
+ const descriptor = await readTableDescriptor(app.sdk, table);
325
415
  const { identifier: _identifier, ...pickerArgs } = argv;
326
416
  return pickDesignerRecord(pickerArgs, app, table, `test record in ${table}`, '', descriptor.displayField);
327
417
  }
@@ -0,0 +1,395 @@
1
+ // Trigger contract for Flow Designer creation.
2
+ //
3
+ // Flow Designer does NOT give a newly POSTed ProcessFlow a default trigger, so
4
+ // the trigger instance must be INSERTED through the same snFlowDesigner
5
+ // GraphQL `flowPatch` that inserts actions. Everything the insert needs is read
6
+ // from the instance's own schema tables — no trigger input is hardcoded:
7
+ //
8
+ // sys_hub_trigger_definition one row per trigger ("Created", "Daily", …)
9
+ // sys_hub_trigger_category the picker's first level
10
+ // sys_hub_trigger_input the inputs a definition declares. The owning
11
+ // definition is carried by `name`, which is
12
+ // `var__m_sys_hub_trigger_input_<definitionId>`;
13
+ // the `model` reference is EMPTY on this
14
+ // instance for most definitions, and a
15
+ // `trigger_definition=` query is silently
16
+ // ignored, so `name` is the only reliable
17
+ // relation.
18
+ // sys_db_object / sys_dictionary the record trigger's Table label and display
19
+ // column (the ProcessFlow
20
+ // /trigger/record/table descriptor endpoint
21
+ // does not exist: HTTP 400 "Requested URI does
22
+ // not represent any resource").
23
+ //
24
+ // `internal_type` on sys_hub_trigger_input holds the parameter's real type
25
+ // ("glide_time", "table_name", "conditions", …) as its raw value; its display
26
+ // value is the human label ("Time", "Table Name", …).
27
+
28
+ export const TRIGGER_DEFINITION_TABLE = 'sys_hub_trigger_definition';
29
+ export const TRIGGER_CATEGORY_TABLE = 'sys_hub_trigger_category';
30
+ export const TRIGGER_INPUT_TABLE = 'sys_hub_trigger_input';
31
+
32
+ export const TRIGGER_INPUT_NAME_PREFIX = 'var__m_sys_hub_trigger_input_';
33
+
34
+ // Families this CLI can actually create, each verified against a live instance
35
+ // with a disposable draft flow (see the PR body for the probe sys_ids). The
36
+ // picker intersects this set with the instance's own active definitions, so an
37
+ // unimplemented family can never be offered.
38
+ export const SUPPORTED_TRIGGER_TYPES = new Set([
39
+ 'record_create',
40
+ 'record_update',
41
+ 'record_create_or_update',
42
+ 'daily',
43
+ 'weekly',
44
+ 'monthly',
45
+ 'repeat',
46
+ 'run_once',
47
+ 'email',
48
+ 'knowledge_management',
49
+ 'service_catalog',
50
+ 'sla_task',
51
+ 'remote_table_query',
52
+ ]);
53
+
54
+ // Definitions that exist on the instance but are deliberately not offered, with
55
+ // the reason. Kept explicit so the picker can explain the omission instead of
56
+ // silently hiding a family.
57
+ export const EXCLUDED_TRIGGER_TYPES = new Map([
58
+ ['analytics', 'the Proactive Analytics trigger definition is inactive on this instance'],
59
+ ['rest', 'the Trigger Rest definition has no resolvable trigger category'],
60
+ ]);
61
+
62
+ // Families whose declared inputs name a table. The manifest's `table` shortcut
63
+ // maps onto whichever of these the chosen definition declares.
64
+ const TABLE_INPUT_NAMES = ['table', 'u_table', 'target_table'];
65
+ // Same idea for the encoded-query condition.
66
+ const CONDITION_INPUT_NAMES = ['condition', 'email_conditions', 'business_calendar_condition'];
67
+
68
+ // Trigger input types this CLI can prompt for. Anything else must be supplied
69
+ // through --data-file so its complete schema and value are preserved.
70
+ export const PROMPTABLE_TRIGGER_TYPES = new Set([
71
+ 'string', 'table_name', 'field_name', 'conditions', 'choice', 'reference',
72
+ 'glide_time', 'glide_date_time', 'glide_duration', 'glide_list',
73
+ 'integer', 'decimal', 'float', 'boolean', 'day_of_week', 'month_of_year',
74
+ 'script', 'html', 'url', 'email', 'document_id', 'date', 'datetime',
75
+ ]);
76
+
77
+ export const NUMERIC_TRIGGER_TYPES = new Set(['integer', 'decimal', 'float']);
78
+
79
+ function rawValue(value) {
80
+ if (value && typeof value === 'object') return value.value ?? value.sys_id ?? value.name ?? '';
81
+ return value == null ? '' : value;
82
+ }
83
+
84
+ function displayValue(value) {
85
+ if (value && typeof value === 'object') return value.display_value ?? value.value ?? '';
86
+ return value == null ? '' : value;
87
+ }
88
+
89
+ function text(value) {
90
+ const result = rawValue(value);
91
+ return result == null ? '' : String(result);
92
+ }
93
+
94
+ export function isSysId(value) {
95
+ return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
96
+ }
97
+
98
+ function boolValue(value) {
99
+ return String(rawValue(value)).toLowerCase() === 'true';
100
+ }
101
+
102
+ function numeric(value, fallback = 0) {
103
+ const parsed = Number(rawValue(value));
104
+ return Number.isFinite(parsed) ? parsed : fallback;
105
+ }
106
+
107
+ /**
108
+ * The category tree the picker shows, built from the instance's own
109
+ * sys_hub_trigger_category rows and the category reference each active
110
+ * definition carries.
111
+ *
112
+ * Definitions whose category does not resolve to a readable category record
113
+ * are returned separately in `ungrouped` — they are never selectable, but the
114
+ * caller can report them.
115
+ */
116
+ export async function readTriggerCatalog(sdk) {
117
+ const [definitions, categories] = await Promise.all([
118
+ sdk.list(TRIGGER_DEFINITION_TABLE, new URLSearchParams({
119
+ sysparm_query: 'active=true^ORDERBYname',
120
+ sysparm_limit: '200',
121
+ sysparm_display_value: 'all',
122
+ sysparm_fields: 'sys_id,name,type,active,category,description',
123
+ })),
124
+ sdk.list(TRIGGER_CATEGORY_TABLE, new URLSearchParams({
125
+ sysparm_limit: '200',
126
+ sysparm_display_value: 'all',
127
+ sysparm_fields: 'sys_id,name',
128
+ })),
129
+ ]);
130
+
131
+ const categoryNames = new Map(categories.map(row => [text(row.sys_id), displayValue(row.name) || text(row.name)]));
132
+ const grouped = new Map();
133
+ const ungrouped = [];
134
+
135
+ for (const row of definitions) {
136
+ const categoryId = text(row.category);
137
+ const categoryName = categoryNames.get(categoryId) || '';
138
+ const definition = {
139
+ sysId: text(row.sys_id),
140
+ type: text(row.type),
141
+ name: displayValue(row.name) || text(row.name),
142
+ description: displayValue(row.description) || '',
143
+ supported: SUPPORTED_TRIGGER_TYPES.has(text(row.type)),
144
+ excludedReason: EXCLUDED_TRIGGER_TYPES.get(text(row.type)) || (SUPPORTED_TRIGGER_TYPES.has(text(row.type)) ? '' : 'this CLI has no verified creation contract for this trigger'),
145
+ };
146
+ if (!categoryId || !categoryName) {
147
+ ungrouped.push(definition);
148
+ continue;
149
+ }
150
+ if (!grouped.has(categoryId)) grouped.set(categoryId, { id: categoryId, name: categoryName, triggers: [] });
151
+ grouped.get(categoryId).triggers.push(definition);
152
+ }
153
+
154
+ const categoriesOut = [...grouped.values()]
155
+ .map(category => ({ ...category, triggers: category.triggers.sort((a, b) => a.name.localeCompare(b.name)) }))
156
+ .sort((a, b) => a.name.localeCompare(b.name));
157
+
158
+ return { categories: categoriesOut, ungrouped };
159
+ }
160
+
161
+ /** The pickable definitions for one category, in the shape the picker needs. */
162
+ export function selectableTriggers(category) {
163
+ return (category?.triggers || []).filter(trigger => trigger.supported);
164
+ }
165
+
166
+ // Definition descriptions are authored HTML; strip it and keep the first
167
+ // sentence so a picker row stays readable.
168
+ export function triggerChoiceLabel(trigger) {
169
+ const text = String(trigger?.description || '')
170
+ .replace(/<[^>]*>/g, ' ')
171
+ .replace(/&[a-z]+;/gi, ' ')
172
+ .replace(/\s+/g, ' ')
173
+ .trim();
174
+ if (!text) return trigger?.name || '';
175
+ const sentence = text.split(/\.\s/)[0];
176
+ return `${trigger.name} — ${sentence.length > 90 ? `${sentence.slice(0, 87)}…` : sentence}`;
177
+ }
178
+
179
+ /**
180
+ * The inputs a trigger definition declares, straight from
181
+ * sys_hub_trigger_input. Answers "what must I supply for this family?" from the
182
+ * instance rather than from a hand-written table.
183
+ */
184
+ export async function readTriggerInputDefinitions(sdk, definitionId) {
185
+ if (!isSysId(definitionId)) throw new Error(`Trigger definition id must be an exact sys_id, got "${definitionId}"`);
186
+ const rows = await sdk.list(TRIGGER_INPUT_TABLE, new URLSearchParams({
187
+ sysparm_query: `name=${TRIGGER_INPUT_NAME_PREFIX}${definitionId}^ORDERBYorder`,
188
+ sysparm_limit: '200',
189
+ sysparm_display_value: 'all',
190
+ sysparm_fields: 'sys_id,element,label,column_label,internal_type,mandatory,order,max_length,default_value,attributes,choice,reference,use_dependent_field,dependent_on_field,read_only,hint',
191
+ }));
192
+ return rows
193
+ .map(normalizeTriggerInput)
194
+ .filter(input => input.name);
195
+ }
196
+
197
+ function normalizeTriggerInput(row) {
198
+ const parameter = {
199
+ id: text(row.sys_id),
200
+ label: displayValue(row.label) || displayValue(row.column_label) || text(row.element),
201
+ name: text(row.element),
202
+ type: text(row.internal_type),
203
+ hint: displayValue(row.hint) || '',
204
+ order: numeric(row.order),
205
+ extended: false,
206
+ mandatory: boolValue(row.mandatory),
207
+ readonly: boolValue(row.read_only),
208
+ maxsize: numeric(row.max_length),
209
+ children: [],
210
+ reference: text(row.reference) ? displayValue(row.reference) : '',
211
+ reference_display: '',
212
+ ref_qual: '',
213
+ choiceOption: text(row.choice),
214
+ table: '',
215
+ columnName: '',
216
+ defaultValue: rawValue(row.default_value) ?? '',
217
+ use_dependent: boolValue(row.use_dependent_field),
218
+ dependent_on: text(row.dependent_on_field),
219
+ show_ref_finder: false,
220
+ local: false,
221
+ // On INPUT the GraphQL type is String; the read path renders it as an object.
222
+ attributes: text(row.attributes),
223
+ };
224
+ return {
225
+ id: parameter.id,
226
+ name: parameter.name,
227
+ label: parameter.label,
228
+ type: parameter.type,
229
+ mandatory: parameter.mandatory,
230
+ order: parameter.order,
231
+ defaultValue: parameter.defaultValue === '' ? '' : String(parameter.defaultValue),
232
+ reference: parameter.reference,
233
+ parameter,
234
+ };
235
+ }
236
+
237
+ /**
238
+ * The record trigger's table metadata. The documented ProcessFlow descriptor
239
+ * route (/api/now/processflow/trigger/record/table) does not exist on this
240
+ * instance, so the label comes from sys_db_object and the display column from
241
+ * the nearest sys_dictionary `display=true` element walking up the
242
+ * super_class chain (a child table such as `ticket` inherits `number` from
243
+ * `task`).
244
+ */
245
+ export async function readTableDescriptor(sdk, table) {
246
+ const name = String(table || '').trim();
247
+ if (!name) throw new Error('Record trigger requires a table name');
248
+ const rows = await sdk.list('sys_db_object', new URLSearchParams({
249
+ sysparm_query: `name=${name}`,
250
+ sysparm_limit: '2',
251
+ sysparm_display_value: 'all',
252
+ sysparm_fields: 'name,label,super_class',
253
+ }));
254
+ if (rows.length !== 1) throw new Error(rows.length === 0 ? `Table not found: ${name}` : `Table name is ambiguous: ${name}`);
255
+ const label = displayValue(rows[0].label) || text(rows[0].label);
256
+ if (!label) throw new Error(`Table ${name} has no label`);
257
+ return { table: name, label, displayField: await readDisplayField(sdk, name, text(rows[0].super_class)) };
258
+ }
259
+
260
+ async function readDisplayField(sdk, table, superId) {
261
+ let current = table;
262
+ let parentId = superId;
263
+ for (let depth = 0; depth < 16 && current; depth += 1) {
264
+ const field = await sdk.list('sys_dictionary', new URLSearchParams({
265
+ sysparm_query: `name=${current}^display=true`,
266
+ sysparm_limit: '5',
267
+ sysparm_display_value: 'all',
268
+ sysparm_fields: 'element',
269
+ }));
270
+ const element = field.map(row => text(row.element)).find(Boolean);
271
+ if (element) return element;
272
+ if (!parentId) return '';
273
+ const parent = await sdk.list('sys_db_object', new URLSearchParams({
274
+ sysparm_query: `sys_id=${parentId}`,
275
+ sysparm_limit: '2',
276
+ sysparm_display_value: 'all',
277
+ sysparm_fields: 'name,super_class',
278
+ }));
279
+ if (parent.length !== 1) return '';
280
+ current = text(parent[0].name);
281
+ parentId = text(parent[0].super_class);
282
+ }
283
+ return '';
284
+ }
285
+
286
+ /** The declared input a manifest shortcut (`table`, `condition`) should drive. */
287
+ export function tableInputFor(declared) {
288
+ return declared.find(input => TABLE_INPUT_NAMES.includes(input.name)) || null;
289
+ }
290
+
291
+ export function conditionInputFor(declared) {
292
+ return declared.find(input => CONDITION_INPUT_NAMES.includes(input.name)) || null;
293
+ }
294
+
295
+ function wrapped(value) {
296
+ const scalar = value === '' || value === null || value === undefined ? '' : String(value);
297
+ return { schemaless: false, schemalessValue: '', value: scalar };
298
+ }
299
+
300
+ function declaredDefault(input) {
301
+ return input.defaultValue === '' || input.defaultValue === undefined ? '' : input.defaultValue;
302
+ }
303
+
304
+ /**
305
+ * Turn a definition's declared inputs plus the caller's values into the exact
306
+ * `inputs` array the snFlowDesigner insert expects.
307
+ *
308
+ * Every declared input is written, using the instance's own default when the
309
+ * caller supplies nothing, because Flow Designer stores the full input set
310
+ * (a real record trigger carries run_on_extended / run_flow_in /
311
+ * run_when_setting / … alongside table and condition).
312
+ */
313
+ export function buildTriggerInputs(declared, supplied = {}, tableDescriptor = null) {
314
+ const byName = new Map(declared.map(input => [input.name, input]));
315
+ for (const name of Object.keys(supplied)) {
316
+ if (!byName.has(name)) throw new Error(`Trigger input "${name}" is not declared by this trigger definition`);
317
+ }
318
+ for (const input of declared) {
319
+ const value = Object.prototype.hasOwnProperty.call(supplied, input.name) ? supplied[input.name] : declaredDefault(input);
320
+ const resolved = value === '' || value === null || value === undefined ? '' : String(value);
321
+ if (resolved === '' && input.mandatory) throw new Error(`Trigger input "${input.name}" (${input.label || input.type}) is required`);
322
+ const tableValue = tableDescriptor && TABLE_INPUT_NAMES.includes(input.name);
323
+ const renderedValue = tableValue ? tableDescriptor.table : resolved;
324
+ const renderedDisplay = tableValue ? tableDescriptor.label : resolved;
325
+ if (!input.parameter?.id) throw new Error(`Trigger input "${input.name}" is missing its provider identity; refusing to invent one`);
326
+ byName.get(input.name).value = renderedValue;
327
+ byName.get(input.name).displayValue = renderedDisplay;
328
+ byName.get(input.name).displayField = tableValue ? tableDescriptor.displayField || '' : '';
329
+ }
330
+ return declared.map(input => ({
331
+ name: input.name,
332
+ value: wrapped(input.value),
333
+ displayValue: wrapped(input.displayValue),
334
+ displayField: input.displayField || '',
335
+ scriptActive: false,
336
+ label: input.parameter.label,
337
+ internalType: input.parameter.type,
338
+ fromTemplate: false,
339
+ order: input.parameter.order,
340
+ valueSysId: '',
341
+ mandatory: input.parameter.mandatory === true,
342
+ parameter: input.parameter,
343
+ }));
344
+ }
345
+
346
+ /** The triggerInstances.insert payload for the snFlowDesigner flow patch. */
347
+ export function buildTriggerInstance({ flowId, definition, triggerType = '', inputs, metadata = '{}' }) {
348
+ if (!flowId) throw new Error('Trigger insert requires the created flow id');
349
+ if (!definition?.sysId || !definition?.type) throw new Error('Trigger insert requires a resolved trigger definition');
350
+ return {
351
+ flowSysId: flowId,
352
+ name: definition.name,
353
+ type: definition.type,
354
+ triggerType: triggerType || '',
355
+ triggerDefinitionId: definition.sysId,
356
+ metadata,
357
+ inputs,
358
+ };
359
+ }
360
+
361
+ // The snFlowDesigner GraphQL input types take `attributes` as a String
362
+ // ("key=value,key=value") even though the ProcessFlow read path renders it as an
363
+ // object. Sending the object fails validation: "not a valid 'String'".
364
+ export function attributesToString(value) {
365
+ if (value === null || value === undefined) return '';
366
+ if (typeof value === 'string') return value;
367
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error('Provider attributes must be a string or a flat object');
368
+ return Object.entries(value).map(([key, item]) => `${key}=${item}`).join(',');
369
+ }
370
+
371
+ // Accepted `parameter` keys on global_snFlowDesigner_FlowDesignParameter.
372
+ // GraphQL introspection is disabled on this instance, so this list was
373
+ // established empirically by probing each candidate field against the live
374
+ // schema (unknown keys answer "contains a field not in
375
+ // 'global_snFlowDesigner_FlowDesignParameter'"). Anything the ProcessFlow
376
+ // action definition returns that is NOT in this list — notably the synthetic
377
+ // `default` this CLI adds, plus `scriptActive`/`value`/`displayValue` — must be
378
+ // carried elsewhere or dropped, or the mutation fails validation.
379
+ const FLOW_DESIGN_PARAMETER_FIELDS = [
380
+ 'id', 'label', 'name', 'type', 'type_label', 'hint', 'order', 'extended',
381
+ 'mandatory', 'readonly', 'maxsize', 'children', 'data_structure', 'reference',
382
+ 'reference_display', 'ref_qual', 'choiceOption', 'table', 'columnName',
383
+ 'defaultValue', 'defaultDisplayValue', 'use_dependent', 'dependent_on',
384
+ 'show_ref_finder', 'local', 'attributes', 'sys_class_name', 'choices',
385
+ 'defaultChoices', 'uiDisplayType', 'uiDisplayTypeLabel', 'placeholder',
386
+ 'comments', 'internal_link', 'dynamic', 'searchField',
387
+ ];
388
+
389
+ /** A provider schema entry narrowed to the GraphQL parameter input type. */
390
+ export function flowDesignParameter(entry) {
391
+ const out = {};
392
+ for (const key of FLOW_DESIGN_PARAMETER_FIELDS) if (entry?.[key] !== undefined) out[key] = entry[key];
393
+ out.attributes = attributesToString(entry?.attributes);
394
+ return out;
395
+ }