@jacebenson/jsn 4.3.9-dev.61 → 4.3.9-dev.63

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.9-dev.61",
3
+ "version": "4.3.9-dev.63",
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": {
@@ -21,7 +21,7 @@ const FLOW_COMMANDS = [
21
21
  { name: 'publish [identifier]', description: 'Publish (activate) a flow, or a custom action with --action' },
22
22
  { name: 'test [identifier]', description: 'Execute a record-triggered flow against one record' },
23
23
  { name: 'create', description: 'Create a draft flow: guided in a terminal, JSON manifest in scripts' },
24
- { name: 'update [identifier]', description: 'Update a draft flow (alias: edit)' },
24
+ { name: 'update [identifier]', description: 'Update a draft, or a published flow whose definition reports write access (alias: edit)' },
25
25
  { name: 'doctor', description: 'Check whether this instance can publish flows at all' },
26
26
  { name: 'delete [identifier]', description: 'Delete a flow (not implemented: use the Flow Designer UI)' },
27
27
  ];
@@ -341,15 +341,19 @@ export function flowsCmd(wrap) {
341
341
  .command({
342
342
  command: 'update [identifier]',
343
343
  aliases: ['edit'],
344
- describe: 'Safely update an inactive Draft flow from a partial JSON manifest. Use `jsn flows schemas` for live trigger inputs and `jsn actions action-type-schema --id <actionTypeId> --scope <scopeId>` for provider action inputs. Manifest structure remains code-owned; omitted values are preserved.',
344
+ describe: 'Save partial edits to a Draft, or to a published/active flow whose ProcessFlow definition reports security.can_write=true: record_create_or_update trigger table/condition and existing action inputs. Published flows are edited as their working definition; the published snapshot and runtime pointers must stay unchanged, and the command fails loudly if they do not. Flows without write access are refused before any lock or content write, naming the permission. Use --preview for before/after values without writes. Flow Logic, variables, catalog-trigger edits and stage placement require Flow Designer. Never publishes or executes. Use `jsn flows schemas` and `jsn actions action-type-schema --id <actionTypeId> --scope <scopeId>` for live schemas; omitted values are preserved.',
345
345
  builder: (y) => y
346
346
  .option('data', { type: 'string', describe: 'Update manifest as inline JSON' })
347
347
  .option('data-file', { type: 'string', describe: 'Read the update manifest from a JSON file' })
348
+ .option('preview', { type: 'boolean', default: false, describe: 'Validate and show exact before/after input values without saving or versioning' })
348
349
  .option('force', { type: 'boolean', default: false, describe: 'Skip confirmation' }),
349
350
  handler: wrap(async (argv, app) => {
350
351
  app.requireInstance();
351
352
  const dataFile = argv.dataFile ?? argv['data-file'];
352
- const identifier = await pickFlow(argv, app, !hasDefinitionData(argv) ? 'active=false^status=draft' : '');
353
+ // No lifecycle filter: published flows are editable when their own
354
+ // definition reports write access, and a non-writable target is
355
+ // refused with a permission message rather than a lifecycle label.
356
+ const identifier = await pickFlow(argv, app);
353
357
  if (identifier === undefined) return;
354
358
  argv = { ...argv, identifier };
355
359
  let manifest;
@@ -360,13 +364,19 @@ export function flowsCmd(wrap) {
360
364
  } catch (error) {
361
365
  throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
362
366
  }
363
- await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
367
+ if (!argv.preview) await confirmDesignerMutation(argv, app, `Update flow ${identifier}; unspecified components and inputs are preserved. A published flow is edited as its working definition: the published snapshot must stay unchanged.`);
364
368
  const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
369
+ preview: argv.preview,
365
370
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
366
371
  });
367
- return app.ok(result, {
368
- summary: `Updated draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
369
- breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
372
+ const diff = result.changes.map(change => `${change.component}${change.order ? ` ${change.order}` : ''}.${change.input}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`).join('\n');
373
+ const state = `${result.flow.status}${result.flow.active ? ', active' : ''}`;
374
+ const preservation = result.lifecycle?.published && !result.preview
375
+ ? `; published snapshot ${result.runtime_preservation?.before?.published_definition?.snapshot_id ?? ''} unchanged`
376
+ : '';
377
+ return app.ok({ ...result, _formatted: diff }, {
378
+ summary: `${result.preview ? 'Preview for' : 'Updated'} flow "${result.flow.name}" (${result.flow.id}) in state ${state}${preservation} without activation`,
379
+ breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated flow' }],
370
380
  });
371
381
  }),
372
382
  })
@@ -1,4 +1,6 @@
1
- import { randomUUID } from 'node:crypto';
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { flowSafeEditRequest, withFlowSafeEdit } from './flow-safe-edit.js';
2
4
  import { assertSafeExactMatch } from './helpers.js';
3
5
  import { createVerifiedFlowVersion } from './flow-versioning.js';
4
6
  import {
@@ -19,6 +21,20 @@ const FLOW_ENDPOINT = '/api/now/processflow/flow';
19
21
  const GRAPHQL_ENDPOINT = '/api/now/graphql';
20
22
  const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
21
23
  const PROTECTED_FLOW_NAME = 'jace-test-flow';
24
+ // Design-time revisions this CLI itself creates. Published version rows must be
25
+ // unchanged by an edit, but every save necessarily adds one Autosave row.
26
+ const AUTOSAVE_VERSION_TYPE = 'Autosave';
27
+ const FLOW_RECORD_FIELDS = 'sys_id,name,sys_scope,scope,active,status,type,sys_updated_on,master_snapshot,latest_snapshot,version_record,version';
28
+
29
+ // Everything this partial-update contract still cannot do, whatever the
30
+ // lifecycle state. Published edits reuse the same component rules.
31
+ const DEFERRED_LIFECYCLE = ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'];
32
+
33
+ function supportedLifecycle(published) {
34
+ return published
35
+ ? ['draft', 'autosave', 'published working-definition edit']
36
+ : ['draft', 'autosave'];
37
+ }
22
38
 
23
39
  function isSysId(value) {
24
40
  return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
@@ -493,6 +509,15 @@ function wrappedValue(value) {
493
509
  return { schemaless: false, schemalessValue: '', value };
494
510
  }
495
511
 
512
+ function maskInputValue(input) {
513
+ const copy = clone(input);
514
+ for (const field of ['value', 'displayValue']) {
515
+ if (copy[field] && typeof copy[field] === 'object' && Object.hasOwn(copy[field], 'value')) copy[field].value = null;
516
+ else if (Object.hasOwn(copy, field)) copy[field] = null;
517
+ }
518
+ return copy;
519
+ }
520
+
496
521
  function buildActionUpdate(existing, requested, index) {
497
522
  const existingInputs = new Set(inputList(existing).map(input => stringValue(input?.name)));
498
523
  for (const name of Object.keys(requested.inputs)) {
@@ -555,21 +580,127 @@ function readFlowRecord(sdk, identifier) {
555
580
  if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
556
581
  assertSafeExactMatch(exactIdentifier);
557
582
  const query = isSysId(exactIdentifier) ? `sys_id=${exactIdentifier}` : `name=${exactIdentifier}`;
558
- return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,scope,active,status,type' }));
583
+ return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: FLOW_RECORD_FIELDS }));
584
+ }
585
+
586
+ // A flow is editable either as an inactive Draft (working definition only) or as
587
+ // a published/active flow whose definition reports write access. `draft` is the
588
+ // only state that skips the provider write-permission check: a published flow is
589
+ // not inherently unwritable, and the decision belongs to the provider.
590
+ export function flowLifecycleState(flow) {
591
+ const active = stringValue(flow.active).toLowerCase() === 'true';
592
+ const status = stringValue(flow.status).toLowerCase();
593
+ return { active, status: status || 'unknown', draft: !active && status === 'draft' };
594
+ }
595
+
596
+ function describeState(lifecycle) {
597
+ const parts = [lifecycle.active ? 'active' : 'inactive'];
598
+ if (lifecycle.status && lifecycle.status !== 'unknown') parts.push(`status=${lifecycle.status}`);
599
+ return parts.join(', ');
559
600
  }
560
601
 
561
- async function resolveDraftFlow(sdk, identifier) {
602
+ async function resolveFlowForUpdate(sdk, identifier) {
562
603
  const rows = await readFlowRecord(sdk, identifier);
563
604
  if (rows.length !== 1) throw new Error(rows.length === 0 ? `Flow not found: ${identifier}` : `Flow identifier is ambiguous: ${identifier}; exact target ID or unique name is required`);
564
605
  const flow = rows[0];
565
606
  const flowId = idValue(flow.sys_id);
566
607
  const flowName = stringValue(flow.name);
567
608
  if (flowId === PROTECTED_FLOW_ID || flowName === PROTECTED_FLOW_NAME) throw new Error(`Protected flow ${PROTECTED_FLOW_NAME} cannot be updated`);
568
- if (String(flow.active?.value ?? flow.active ?? '').toLowerCase() === 'true' || String(flow.status?.value ?? flow.status ?? '').toLowerCase() !== 'draft') throw new Error(`Flow ${flowId} is not an inactive Draft; refusing update`);
569
- return { flow, flowId, flowName };
609
+ return { flow, flowId, flowName, lifecycle: flowLifecycleState(flow) };
610
+ }
611
+
612
+ // The ProcessFlow definition is the write contract for a published flow. A
613
+ // missing or false can_write means the provider will reject the content patch
614
+ // with "Insufficient access level for operation [write]", so refuse before any
615
+ // edit session exists and say which permission is missing.
616
+ function assertDefinitionWritable(flowId, scope, lifecycle, definition) {
617
+ if (lifecycle.draft) return;
618
+ const security = definition?.security;
619
+ const canWrite = security && typeof security === 'object' ? security.can_write : undefined;
620
+ if (canWrite === true) return;
621
+ const reported = canWrite === false ? 'security.can_write=false' : 'no security.can_write';
622
+ throw new Error(`Flow ${flowId} is ${describeState(lifecycle)} and its ProcessFlow definition reports ${reported}, so the provider would reject a content write with "Insufficient access level for operation [write]". This is a flow content-permission problem, not a published-flow limitation: no edit session, content write or Autosave was attempted. Use an account/scope with write access to the flow's application (${scope}) or edit the flow in Flow Designer as a user who can write it. Published snapshots were not touched.`);
623
+ }
624
+
625
+ function digestDefinition(definition) {
626
+ return createHash('sha256').update(JSON.stringify(definition)).digest('hex');
627
+ }
628
+
629
+ // Runtime state of a published flow: the parent pointers that decide whether it
630
+ // runs, the frozen published definition read through its master snapshot id, and
631
+ // the version rows that a publish created. Autosave rows are excluded because
632
+ // every accepted edit adds exactly one design-time revision.
633
+ async function readFlowRuntime(sdk, instance, flowId, { scope, snapshotId }) {
634
+ const records = await sdk.list('sys_hub_flow', new URLSearchParams({
635
+ sysparm_query: `sys_id=${flowId}`,
636
+ sysparm_limit: '1',
637
+ sysparm_display_value: 'all',
638
+ sysparm_fields: FLOW_RECORD_FIELDS,
639
+ }));
640
+ if (records.length !== 1) throw new Error(`Flow ${flowId} runtime read returned ${records.length} records; cannot verify published state`);
641
+ const record = records[0];
642
+ const pointers = {
643
+ active: stringValue(record.active),
644
+ status: stringValue(record.status),
645
+ master_snapshot: stringValue(record.master_snapshot),
646
+ latest_snapshot: stringValue(record.latest_snapshot),
647
+ version_record: stringValue(record.version_record),
648
+ };
649
+ let publishedDefinition;
650
+ if (snapshotId) {
651
+ const response = await sdk.request(`${instance}${FLOW_ENDPOINT}/${encodeURIComponent(snapshotId)}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
652
+ const definition = findFlowData(response);
653
+ if (!definition || typeof definition !== 'object') throw new Error(`Published snapshot ${snapshotId} for flow ${flowId} was unreadable; refusing to compare published runtime state`);
654
+ publishedDefinition = { snapshot_id: snapshotId, digest: digestDefinition(definition) };
655
+ } else {
656
+ publishedDefinition = { snapshot_id: '', digest: '' };
657
+ }
658
+ const versionRows = await sdk.list('sys_hub_flow_version', new URLSearchParams({
659
+ sysparm_query: `flow=${flowId}^ORDERBYDESCsys_updated_on`,
660
+ sysparm_limit: '50',
661
+ sysparm_display_value: 'all',
662
+ sysparm_fields: 'sys_id,flow,type,sys_created_on',
663
+ }));
664
+ const versionRowsStable = versionRows
665
+ .map(row => ({ sys_id: stringValue(row.sys_id), type: stringValue(row.type), created: stringValue(row.sys_created_on) }))
666
+ .filter(row => row.type !== AUTOSAVE_VERSION_TYPE)
667
+ .sort((left, right) => left.sys_id.localeCompare(right.sys_id));
668
+ return { record: pointers, published_definition: publishedDefinition, version_rows: versionRowsStable };
669
+ }
670
+
671
+ // Published runtime must not move. Two parent fields do move by platform design
672
+ // when a published flow is saved: the record's status becomes a draft working
673
+ // copy and version_record points at the new Autosave. Those are accepted only in
674
+ // that exact shape (and reported), because the runtime gates are `active`, the
675
+ // frozen snapshot pointers and the published definition itself.
676
+ function runtimeDifferences(before, after, { autosaveVersionId, newVersionType } = {}) {
677
+ const differences = [];
678
+ const checkout = [];
679
+ for (const field of ['active', 'master_snapshot', 'latest_snapshot']) {
680
+ if (before.record[field] !== after.record[field]) differences.push({ field, before: before.record[field], after: after.record[field] });
681
+ }
682
+ if (before.record.status !== after.record.status) {
683
+ const publishedToDraft = before.record.status === 'published' && after.record.status === 'draft';
684
+ const entry = { field: 'status', before: before.record.status, after: after.record.status };
685
+ if (publishedToDraft) checkout.push({ ...entry, reason: 'platform checkout: the published flow now has a draft working copy; the published snapshot still runs' });
686
+ else differences.push(entry);
687
+ }
688
+ if (before.record.version_record !== after.record.version_record) {
689
+ const entry = { field: 'version_record', before: before.record.version_record, after: after.record.version_record };
690
+ if (autosaveVersionId && after.record.version_record === autosaveVersionId) checkout.push({ ...entry, reason: `pointer moved to this command's ${newVersionType || 'Autosave'} version` });
691
+ else differences.push(entry);
692
+ }
693
+ if (before.published_definition.digest !== after.published_definition.digest) {
694
+ differences.push({ field: 'published_definition', before: before.published_definition, after: after.published_definition });
695
+ }
696
+ if (JSON.stringify(before.version_rows) !== JSON.stringify(after.version_rows)) {
697
+ differences.push({ field: 'published_version_rows', before: before.version_rows, after: after.version_rows });
698
+ }
699
+ return { differences, design_time_changes: checkout };
570
700
  }
571
701
 
572
702
  function updatedTrigger(flow, manifest, descriptor) {
703
+ if (findArray(flow, 'triggerInstances').length !== 1) throw new Error('Saved flow must have exactly one trigger for trigger updates');
573
704
  const existing = clone(findArray(flow, 'triggerInstances')[0]);
574
705
  if (!existing) throw new Error('Saved flow has no trigger instance');
575
706
  const existingMetadata = parseMaybeJson(existing.metadata, null);
@@ -595,14 +726,23 @@ function updatedTrigger(flow, manifest, descriptor) {
595
726
  }
596
727
  const id = findId(existing, ['sysId', 'sys_id', 'id', 'triggerId']);
597
728
  if (!id) throw new Error('Saved record trigger has no usable identity');
598
- const update = { id, inputs };
729
+ const changed = inputs.filter(input => manifest.trigger[input.name] !== undefined);
730
+ const update = { id, inputs: changed.map(input => ({
731
+ ...(input.id ? { id: input.id } : {}),
732
+ name: input.name,
733
+ displayField: input.displayField,
734
+ scriptActive: input.scriptActive,
735
+ ...(input.parameter ? { parameter: flowDesignParameter(input.parameter) } : {}),
736
+ value: input.value,
737
+ displayValue: input.displayValue,
738
+ })) };
599
739
  if (manifest.trigger.condition !== undefined) {
600
740
  // The instance re-derives predicate metadata from the condition input; see
601
741
  // DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
602
742
  // derived predicate matches what was asked for.
603
743
  update.metadata = DEFAULT_TRIGGER_METADATA;
604
744
  } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
605
- update.metadata = existing.metadata;
745
+ update.metadata = typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata);
606
746
  }
607
747
  return { update, currentTable: table };
608
748
  }
@@ -769,18 +909,42 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
769
909
 
770
910
  export async function updateFlowFromManifest(sdk, instance, identifier, inputManifest, options = {}) {
771
911
  const manifest = validateFlowUpdateManifest(inputManifest);
772
- const target = await resolveDraftFlow(sdk, identifier);
912
+ const target = await resolveFlowForUpdate(sdk, identifier);
773
913
  const flowId = target.flowId;
774
- const scope = stringValue(manifest.scope ?? target.flow.scope);
914
+ const lifecycle = target.lifecycle;
915
+ const published = !lifecycle.draft;
916
+ const targetScope = stringValue(target.flow.sys_scope) || stringValue(target.flow.scope);
917
+ const scope = manifest.scope ?? targetScope;
775
918
  const readUpdateSet = options.readUpdateSet || (async () => null);
776
919
  if (!scope) throw new Error(`Flow ${flowId} has no transaction scope`);
777
- if (manifest.scope !== undefined && stringValue(target.flow.scope) && manifest.scope !== stringValue(target.flow.scope)) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
778
- const beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
920
+ if (manifest.scope !== undefined && targetScope && manifest.scope !== targetScope) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
921
+ const snapshotId = stringValue(target.flow.master_snapshot);
922
+ // A published/active target is judged by the provider, not by its lifecycle
923
+ // label: read the definition and require security.can_write before any lock.
924
+ const definitionRead = async () => {
925
+ const definition = await readProcessFlow(sdk, instance, flowId, scope);
926
+ assertDefinitionWritable(flowId, scope, lifecycle, definition);
927
+ return definition;
928
+ };
929
+ let beforeDefinition = await definitionRead();
930
+ // A legacy ProcessFlow read can expose the preceding version's timestamp
931
+ // immediately after Autosave. Never substitute the newer Table timestamp into
932
+ // an older definition: reread the whole document, then let safeEdit check it.
933
+ const recordUpdated = stringValue(target.flow.sys_updated_on);
934
+ if (recordUpdated && beforeDefinition.updated !== recordUpdated) {
935
+ // A publish moves the record, not the definition, so a published flow may
936
+ // legitimately report an older definition timestamp. The definition's own
937
+ // `updated` is what safeEdit checks; only a draft must match its record.
938
+ beforeDefinition = await definitionRead();
939
+ if (!published && beforeDefinition.updated !== recordUpdated) throw new Error(`Flow ${flowId} definition is stale relative to its record; no edit session or content mutation attempted`);
940
+ }
941
+ if (published && !snapshotId) throw new Error(`Flow ${flowId} is ${describeState(lifecycle)} but has no master_snapshot; refusing to edit without a published-definition baseline`);
779
942
  const snapshots = [];
780
943
  const triggerUpdates = [];
781
944
  const actionUpdates = [];
782
945
  const changedActionInputs = new Map();
783
946
  const actionTargets = new Map();
947
+ const changes = [];
784
948
  let triggerUpdate;
785
949
  if (manifest.trigger !== undefined) {
786
950
  const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
@@ -790,6 +954,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
790
954
  : await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
791
955
  triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
792
956
  triggerUpdates.push(triggerUpdate);
957
+ for (const input of ['table', 'condition']) {
958
+ if (manifest.trigger[input] !== undefined) changes.push({ component: 'trigger', input, before: inputRawValue(inputList(currentTrigger).find(field => field.name === input)), after: manifest.trigger[input] });
959
+ }
793
960
  }
794
961
  const existingActions = findExistingActions(beforeDefinition);
795
962
  for (const [index, requested] of (manifest.actions || []).entries()) {
@@ -797,78 +964,166 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
797
964
  const update = buildActionUpdate(existing, requested, index);
798
965
  actionUpdates.push(update);
799
966
  const uiId = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
967
+ if (actionTargets.has(uiId)) throw new Error(`Duplicate action update for order ${requested.order}; combine input changes in one action entry`);
800
968
  actionTargets.set(uiId, existing);
801
969
  changedActionInputs.set(uiId, new Set(Object.keys(requested.inputs)));
970
+ for (const [input, after] of Object.entries(requested.inputs)) changes.push({ component: 'action', order: requested.order, type: requested.type, input, before: inputRawValue(inputList(existing).find(field => field.name === input)), after });
802
971
  }
803
972
  if (triggerUpdates.length === 0 && actionUpdates.length === 0) throw new Error(`Flow ${flowId} update has no supported changes`);
804
-
805
- try {
806
- const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
807
- method: 'POST',
808
- body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
809
- }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
810
- assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
811
- const processflow = await readProcessFlow(sdk, instance, flowId, scope);
812
- const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
813
- if (triggerUpdate) {
814
- if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
815
- const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
816
- const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
817
- const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
818
- if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
819
- const readbackInputs = inputList(readbackTrigger);
820
- const beforeInputs = inputList(beforeTrigger);
821
- if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
822
- const requestedInputs = new Set();
823
- if (manifest.trigger.table !== undefined) requestedInputs.add('table');
824
- if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
825
- for (const beforeInput of beforeInputs) {
826
- const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
827
- if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
828
- if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
973
+ if (options.preview) return {
974
+ preview: true,
975
+ flow: { id: flowId, name: target.flowName, status: lifecycle.status, active: lifecycle.active },
976
+ changes,
977
+ lifecycle: {
978
+ published,
979
+ active: lifecycle.active,
980
+ executed: false,
981
+ writable: true,
982
+ lock_acquired: false,
983
+ supported: supportedLifecycle(published),
984
+ deferred: DEFERRED_LIFECYCLE,
985
+ },
986
+ };
987
+ // Baseline the published runtime after the definition read (a legacy read can
988
+ // materialize a version row) and before the edit session exists.
989
+ const runtimeBefore = published ? await readFlowRuntime(sdk, instance, flowId, { scope, snapshotId }) : null;
990
+ const outcome = await withFlowSafeEdit(sdk, instance, flowId, beforeDefinition.updated, async () => {
991
+ let completedStage = 'validated';
992
+ try {
993
+ const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
994
+ method: 'POST',
995
+ body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
996
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
997
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
998
+ completedStage = 'patch_acknowledged';
999
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
1000
+ // Only requested values may differ. Preserve array order and opaque fields.
1001
+ for (const field of ['flowVariables', 'flowLogicInstances', 'subFlowInstances', 'flowInputs', 'flowOutputs', 'inputs', 'outputs', 'stages']) {
1002
+ if (!isDeepStrictEqual(beforeDefinition[field], processflow[field])) throw new Error(`Flow ${flowId} changed omitted ${field}; preservation failed`);
829
1003
  }
830
- if (manifest.trigger.table !== undefined && inputRawValue(readbackInputs.find(input => input.name === 'table')) !== manifest.trigger.table) throw new Error(`Flow ${flowId} trigger table readback did not match the requested value`);
831
- if (manifest.trigger.condition !== undefined) {
832
- 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`);
833
- assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
834
- const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
835
- const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
836
- if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
837
- } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
838
- throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
1004
+ if (!triggerUpdate && !isDeepStrictEqual(beforeDefinition.triggerInstances, processflow.triggerInstances)) throw new Error(`Flow ${flowId} changed omitted triggerInstances`);
1005
+ const maskActions = definition => findExistingActions(definition).map(action => {
1006
+ const copy = clone(action);
1007
+ const changed = changedActionInputs.get(stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier));
1008
+ for (const [index, input] of inputList(copy).entries()) if (changed?.has(input.name)) inputList(copy)[index] = maskInputValue(input);
1009
+ return copy;
1010
+ });
1011
+ if (!isDeepStrictEqual(maskActions(beforeDefinition), maskActions(processflow))) throw new Error(`Flow ${flowId} changed omitted action fields; preservation failed`);
1012
+ const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
1013
+ if (triggerUpdate) {
1014
+ if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
1015
+ const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
1016
+ const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
1017
+ const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
1018
+ if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
1019
+ const readbackInputs = inputList(readbackTrigger);
1020
+ const beforeInputs = inputList(beforeTrigger);
1021
+ if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
1022
+ const requestedInputs = new Set();
1023
+ if (manifest.trigger.table !== undefined) requestedInputs.add('table');
1024
+ if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
1025
+ const maskTriggerInputs = inputs => inputs.map(input => {
1026
+ if (!requestedInputs.has(input.name)) return input;
1027
+ const copy = maskInputValue(input);
1028
+ delete copy.displayField;
1029
+ // Live table saves materialize only this absent display label as ''.
1030
+ // Do not collapse named values, defaults or arbitrary empty fields.
1031
+ if (copy.parameter?.type_label === '') delete copy.parameter.type_label;
1032
+ return copy;
1033
+ });
1034
+ if (!isDeepStrictEqual(maskTriggerInputs(beforeInputs), maskTriggerInputs(readbackInputs))) throw new Error(`Flow ${flowId} trigger input preservation failed`);
1035
+ for (const beforeInput of beforeInputs) {
1036
+ const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
1037
+ if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
1038
+ if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
1039
+ }
1040
+ if (manifest.trigger.table !== undefined && inputRawValue(readbackInputs.find(input => input.name === 'table')) !== manifest.trigger.table) throw new Error(`Flow ${flowId} trigger table readback did not match the requested value`);
1041
+ if (manifest.trigger.condition !== undefined) {
1042
+ 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`);
1043
+ assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
1044
+ const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
1045
+ const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
1046
+ if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
1047
+ } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
1048
+ throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
1049
+ }
839
1050
  }
840
- }
841
- const readbackActions = findExistingActions(processflow);
842
- for (const update of actionUpdates) {
843
- const uiId = stringValue(update.uiUniqueIdentifier);
844
- const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
845
- if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
846
- const before = actionTargets.get(uiId);
847
- const changed = changedActionInputs.get(uiId) || new Set();
848
- for (const input of inputList(before)) {
849
- const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
850
- if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
851
- if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
852
- if (changed.has(input.name) && inputRawValue(afterInput) !== inputRawValue(update.inputs.find(candidate => candidate.name === input.name))) throw new Error(`Flow ${flowId} Action ${uiId} input ${input.name} did not match the requested value`);
1051
+ const readbackActions = findExistingActions(processflow);
1052
+ for (const update of actionUpdates) {
1053
+ const uiId = stringValue(update.uiUniqueIdentifier);
1054
+ const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
1055
+ if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
1056
+ const before = actionTargets.get(uiId);
1057
+ const changed = changedActionInputs.get(uiId) || new Set();
1058
+ for (const input of inputList(before)) {
1059
+ const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
1060
+ if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
1061
+ if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
1062
+ if (changed.has(input.name) && inputRawValue(afterInput) !== inputRawValue(update.inputs.find(candidate => candidate.name === input.name))) throw new Error(`Flow ${flowId} Action ${uiId} input ${input.name} did not match the requested value`);
1063
+ }
853
1064
  }
1065
+ completedStage = 'definition_verified';
1066
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
1067
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
1068
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
1069
+ completedStage = 'autosave_verified';
1070
+ let runtime;
1071
+ if (published) {
1072
+ runtime = { before: runtimeBefore, after: await readFlowRuntime(sdk, instance, flowId, { scope, snapshotId }) };
1073
+ const comparison = runtimeDifferences(runtime.before, runtime.after, {
1074
+ autosaveVersionId: stringValue(version?.sys_id || version?.sysId),
1075
+ newVersionType: stringValue(version?.type) || AUTOSAVE_VERSION_TYPE,
1076
+ });
1077
+ if (comparison.differences.length) {
1078
+ throw new Error(`Flow ${flowId} was edited but its published runtime state changed: ${JSON.stringify(comparison.differences)}. The edit may have altered runtime. Do not republish, retry, unpublish or deactivate; inspect published snapshot ${snapshotId}, the version rows and the active/status pointers in Flow Designer. No rollback, publish or execution was attempted.`);
1079
+ }
1080
+ runtime.verified = true;
1081
+ runtime.design_time_changes = comparison.design_time_changes;
1082
+ completedStage = 'runtime_verified';
1083
+ }
1084
+ const selectedUpdateSet = await readUpdateSet();
1085
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
1086
+ // Report what the record says after the save, not the pre-edit label: a
1087
+ // published flow that now has a draft working copy must not look published
1088
+ // in the result. `active` and the snapshot pointers are the runtime gates.
1089
+ const finalRecord = runtime?.after?.record;
1090
+ return {
1091
+ flow: {
1092
+ id: flowId,
1093
+ name: target.flowName,
1094
+ status: finalRecord?.status || lifecycle.status,
1095
+ active: finalRecord ? finalRecord.active === 'true' : lifecycle.active,
1096
+ },
1097
+ changes,
1098
+ processflow,
1099
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
1100
+ version,
1101
+ ...(runtime ? { runtime_preservation: runtime } : {}),
1102
+ update_set_capture: updateSetCapture,
1103
+ update_set_mutations: snapshots,
1104
+ lifecycle: {
1105
+ published,
1106
+ active: lifecycle.active,
1107
+ executed: false,
1108
+ runtime_preserved: published ? true : undefined,
1109
+ supported: supportedLifecycle(published),
1110
+ deferred: DEFERRED_LIFECYCLE,
1111
+ },
1112
+ };
1113
+ } catch (error) {
1114
+ throw new Error(`Flow ${flowId} update incomplete; last completed stage: ${completedStage}. Changes may already be persisted; inspect this exact flow before retrying. No rollback, publish or execution attempted. ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
854
1115
  }
855
- const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
856
- instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
857
- }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
858
- const selectedUpdateSet = await readUpdateSet();
859
- const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
860
- return {
861
- flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
862
- processflow,
863
- definition_readback: { available: true, status: 'read_back', flow_id: flowId },
864
- version,
865
- update_set_capture: updateSetCapture,
866
- update_set_mutations: snapshots,
867
- lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
868
- };
869
- } catch (error) {
870
- throw new Error(`Flow ${flowId} update incomplete: ${error.message}; reconciliation: ${JSON.stringify(snapshots)}`, { cause: error });
1116
+ });
1117
+ if (published) {
1118
+ // The lease is user-level, so its absence is part of the delivered result.
1119
+ const { status } = await flowSafeEditRequest(sdk, instance, flowId, 'read');
1120
+ if (!status || status.canUserEdit || status.currentEditor) {
1121
+ throw new Error(`Flow ${flowId} update saved (Autosave ${stringValue(outcome.version?.sys_id)}) but its safe edit lease is still present: ${JSON.stringify(status ?? null)}. Inspect and close the Flow Designer session before further edits. No rollback or unpublish attempted.`);
1122
+ }
1123
+ outcome.lifecycle = { ...outcome.lifecycle, safe_edit_lease: 'absent' };
1124
+ outcome.runtime_preservation = { ...outcome.runtime_preservation, safe_edit_lease: 'absent' };
871
1125
  }
1126
+ return outcome;
872
1127
  }
873
1128
 
874
1129
  export async function readCurrentUpdateSet(sdk) {
@@ -370,16 +370,21 @@ export async function savedFlowDefinition(app, identifier) {
370
370
  const { resolveRecord, unwrapSysId } = await import('./resolve-record.js');
371
371
  const record = await resolveRecord(app.sdk, { table: 'sys_hub_flow', identifier, matchField: 'name', resource: 'flow' });
372
372
  const id = unwrapSysId(record);
373
- const scope = rawField(record.scope);
373
+ const scope = rawField(record.sys_scope) || rawField(record.scope);
374
374
  if (!scope) throw errUsage('Saved flow has no raw transaction scope');
375
375
  return { record, definition: await readProcessFlow(app.sdk, app.getEffectiveInstance(), id, scope) };
376
376
  }
377
377
  export async function guidedFlowEdit(argv, app) {
378
378
  if (!interactiveDesigner(argv, app)) throw errUsage('Provide --data-file or --data for the flow update manifest in noninteractive mode');
379
379
  const { record, definition } = await savedFlowDefinition(app, argv.identifier);
380
- if (rawField(record.active) === true || rawField(record.active) === 'true' || rawField(record.status) !== 'draft') throw errUsage('Guided flow edit requires an inactive Draft');
380
+ // Published/active targets are judged by the provider, exactly as the scripted
381
+ // path does: an inactive Draft needs no write check, anything else must report
382
+ // security.can_write=true or the save would be rejected after this wizard.
383
+ const active = rawField(record.active) === true || rawField(record.active) === 'true';
384
+ const draft = !active && rawField(record.status) === 'draft';
385
+ if (!draft && definition?.security?.can_write !== true) throw errUsage(`Guided flow edit needs the flow's ProcessFlow definition to report security.can_write=true; ${argv.identifier} does not, so the provider would reject the save. Edit it in Flow Designer as a user who can write this flow.`);
381
386
  const manifest = {};
382
- const choice = await prompts(app).select({ message: 'Edit supported draft flow fields', choices: [
387
+ const choice = await prompts(app).select({ message: 'Edit supported flow fields', choices: [
383
388
  { name: 'Record trigger condition', value: 'trigger' }, { name: 'Existing action input values', value: 'actions' },
384
389
  ] });
385
390
  if (choice === 'trigger') {
@@ -396,7 +401,7 @@ export async function guidedFlowEdit(argv, app) {
396
401
  const actionType = rawField(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId);
397
402
  let inputSchema = { ...field, type: field.type || field.parameter?.type };
398
403
  if (!inputSchema.type) {
399
- const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.scope), { requireTypes: true });
404
+ const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.sys_scope) || rawField(record.scope), { requireTypes: true });
400
405
  inputSchema = schema.find(candidate => candidate.name === field.name);
401
406
  if (!inputSchema) throw errUsage(`Saved action input ${field.name} is absent from its provider schema`);
402
407
  }
@@ -0,0 +1,48 @@
1
+ // Flow Designer's safeEdit lease is separate from publishing and artifact deletion.
2
+ // The provider exposes a user-level lock, not an operation token: refuse even a
3
+ // same-user existing lease so this CLI cannot release an open designer session.
4
+ export async function flowSafeEditRequest(sdk, instance, flowId, operation, lastUpdated) {
5
+ const id = JSON.stringify(flowId);
6
+ const input = operation === 'upsert' ? `upsert:{flowId:${id} lastUpdated:${JSON.stringify(lastUpdated)}}` : `${operation}:${id}`;
7
+ const selection = operation === 'delete' ? 'deleteResult{deleteSuccess id}' : `status{canUserEdit currentEditor${operation === 'upsert' ? ' clientFlowStale' : ''}}`;
8
+ const query = `${operation === 'read' ? 'query' : 'mutation'} {global {snFlowDesigner {safeEdit(safeEditInput:{${input}}){${selection}}}}}`;
9
+ const response = await sdk.request(`${instance}/api/now/graphql`, { method: 'POST', body: JSON.stringify({ query, variables: {} }) });
10
+ if (response.errors?.length) throw new Error(`Flow safe edit ${operation}: ${response.errors.map(error => error.message).join('; ')}`);
11
+ const result = response.data?.global?.snFlowDesigner?.safeEdit;
12
+ if (!result) throw new Error(`Flow safe edit ${operation} returned no result`);
13
+ return result;
14
+ }
15
+
16
+ export async function withFlowSafeEdit(sdk, instance, flowId, lastUpdated, operation) {
17
+ if (typeof lastUpdated !== 'string' || !lastUpdated) throw new Error(`Flow ${flowId} has no authoritative updated timestamp; safe edit refused`);
18
+ const read = async () => {
19
+ const { status } = await flowSafeEditRequest(sdk, instance, flowId, 'read');
20
+ if (!status || typeof status.canUserEdit !== 'boolean' || !(status.currentEditor == null || typeof status.currentEditor === 'string')) throw new Error('Unreadable safe edit status');
21
+ return status;
22
+ };
23
+ const before = await read();
24
+ if (before.canUserEdit || before.currentEditor) throw new Error(`Flow ${flowId} already has an existing edit session/editor; close it in Flow Designer before retrying. No lock changed.`);
25
+ let status;
26
+ try {
27
+ ({ status } = await flowSafeEditRequest(sdk, instance, flowId, 'upsert', lastUpdated));
28
+ } catch (error) {
29
+ throw new Error(`Flow ${flowId} safe edit acquisition is uncertain: ${error.message}. No content write attempted; no release attempted because ownership is unconfirmed. Inspect the exact flow/session before retrying.`, { cause: error });
30
+ }
31
+ // A denied or ambiguous upsert is not proof we own a lease. Never delete it.
32
+ if (status?.canUserEdit !== true || status.currentEditor) throw new Error(`Flow ${flowId} safe edit acquisition denied or ambiguous${status?.clientFlowStale === true ? ' (client flow is stale)' : ' (another editor may own it)'}; no content write or release attempted`);
33
+ let result, failure;
34
+ try {
35
+ if (status.clientFlowStale !== false && status.clientFlowStale !== null) throw new Error(`Flow ${flowId} is stale or has unreadable freshness; reread before retrying`);
36
+ result = await operation();
37
+ } catch (error) { failure = error; }
38
+ try {
39
+ const { deleteResult } = await flowSafeEditRequest(sdk, instance, flowId, 'delete');
40
+ if (deleteResult?.deleteSuccess !== true || deleteResult.id !== flowId) throw new Error('provider did not confirm release');
41
+ const after = await read();
42
+ if (after.canUserEdit || after.currentEditor) throw new Error('release readback still reports an edit session');
43
+ } catch (error) {
44
+ throw new Error(`${failure ? `${failure.message}; ` : 'Content operation completed; '}Flow ${flowId} safe edit release failed: ${error.message}. Inspect the exact flow/session before retrying.`, { cause: error });
45
+ }
46
+ if (failure) throw failure;
47
+ return result;
48
+ }