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

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.62",
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": {
@@ -341,10 +341,11 @@ 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 an inactive Draft: record_create_or_update trigger table/condition and existing action inputs. Use --preview for before/after values without writes. Published checkout, 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();
@@ -360,12 +361,14 @@ export function flowsCmd(wrap) {
360
361
  } catch (error) {
361
362
  throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
362
363
  }
363
- await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
364
+ if (!argv.preview) await confirmDesignerMutation(argv, app, `Update draft flow ${identifier}; unspecified components and inputs are preserved`);
364
365
  const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
366
+ preview: argv.preview,
365
367
  readUpdateSet: () => readCurrentUpdateSet(app.sdk),
366
368
  });
367
- return app.ok(result, {
368
- summary: `Updated draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
369
+ const diff = result.changes.map(change => `${change.component}${change.order ? ` ${change.order}` : ''}.${change.input}: ${JSON.stringify(change.before)} -> ${JSON.stringify(change.after)}`).join('\n');
370
+ return app.ok({ ...result, _formatted: diff }, {
371
+ summary: `${result.preview ? 'Preview for' : 'Updated'} draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
369
372
  breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
370
373
  });
371
374
  }),
@@ -1,4 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { withFlowSafeEdit } from './flow-safe-edit.js';
2
4
  import { assertSafeExactMatch } from './helpers.js';
3
5
  import { createVerifiedFlowVersion } from './flow-versioning.js';
4
6
  import {
@@ -493,6 +495,15 @@ function wrappedValue(value) {
493
495
  return { schemaless: false, schemalessValue: '', value };
494
496
  }
495
497
 
498
+ function maskInputValue(input) {
499
+ const copy = clone(input);
500
+ for (const field of ['value', 'displayValue']) {
501
+ if (copy[field] && typeof copy[field] === 'object' && Object.hasOwn(copy[field], 'value')) copy[field].value = null;
502
+ else if (Object.hasOwn(copy, field)) copy[field] = null;
503
+ }
504
+ return copy;
505
+ }
506
+
496
507
  function buildActionUpdate(existing, requested, index) {
497
508
  const existingInputs = new Set(inputList(existing).map(input => stringValue(input?.name)));
498
509
  for (const name of Object.keys(requested.inputs)) {
@@ -555,7 +566,7 @@ function readFlowRecord(sdk, identifier) {
555
566
  if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
556
567
  assertSafeExactMatch(exactIdentifier);
557
568
  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' }));
569
+ return sdk.list('sys_hub_flow', new URLSearchParams({ sysparm_query: query, sysparm_limit: '2', sysparm_display_value: 'all', sysparm_fields: 'sys_id,name,sys_scope,scope,active,status,type,sys_updated_on' }));
559
570
  }
560
571
 
561
572
  async function resolveDraftFlow(sdk, identifier) {
@@ -565,11 +576,12 @@ async function resolveDraftFlow(sdk, identifier) {
565
576
  const flowId = idValue(flow.sys_id);
566
577
  const flowName = stringValue(flow.name);
567
578
  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`);
579
+ if (String(flow.active?.value ?? flow.active ?? '').toLowerCase() !== 'false' || String(flow.status?.value ?? flow.status ?? '').toLowerCase() !== 'draft') throw new Error(`Flow ${flowId} is not an inactive Draft; refusing update. Published checkout is not verified by this CLI. Use Flow Designer to check out and edit published flows; save, publish and execution are separate operations.`);
569
580
  return { flow, flowId, flowName };
570
581
  }
571
582
 
572
583
  function updatedTrigger(flow, manifest, descriptor) {
584
+ if (findArray(flow, 'triggerInstances').length !== 1) throw new Error('Saved flow must have exactly one trigger for trigger updates');
573
585
  const existing = clone(findArray(flow, 'triggerInstances')[0]);
574
586
  if (!existing) throw new Error('Saved flow has no trigger instance');
575
587
  const existingMetadata = parseMaybeJson(existing.metadata, null);
@@ -595,14 +607,23 @@ function updatedTrigger(flow, manifest, descriptor) {
595
607
  }
596
608
  const id = findId(existing, ['sysId', 'sys_id', 'id', 'triggerId']);
597
609
  if (!id) throw new Error('Saved record trigger has no usable identity');
598
- const update = { id, inputs };
610
+ const changed = inputs.filter(input => manifest.trigger[input.name] !== undefined);
611
+ const update = { id, inputs: changed.map(input => ({
612
+ ...(input.id ? { id: input.id } : {}),
613
+ name: input.name,
614
+ displayField: input.displayField,
615
+ scriptActive: input.scriptActive,
616
+ ...(input.parameter ? { parameter: flowDesignParameter(input.parameter) } : {}),
617
+ value: input.value,
618
+ displayValue: input.displayValue,
619
+ })) };
599
620
  if (manifest.trigger.condition !== undefined) {
600
621
  // The instance re-derives predicate metadata from the condition input; see
601
622
  // DEFAULT_TRIGGER_METADATA. The read-back check below still proves the
602
623
  // derived predicate matches what was asked for.
603
624
  update.metadata = DEFAULT_TRIGGER_METADATA;
604
625
  } else if (Object.prototype.hasOwnProperty.call(existing, 'metadata')) {
605
- update.metadata = existing.metadata;
626
+ update.metadata = typeof existing.metadata === 'string' ? existing.metadata : JSON.stringify(existing.metadata);
606
627
  }
607
628
  return { update, currentTable: table };
608
629
  }
@@ -771,16 +792,26 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
771
792
  const manifest = validateFlowUpdateManifest(inputManifest);
772
793
  const target = await resolveDraftFlow(sdk, identifier);
773
794
  const flowId = target.flowId;
774
- const scope = stringValue(manifest.scope ?? target.flow.scope);
795
+ const targetScope = stringValue(target.flow.sys_scope) || stringValue(target.flow.scope);
796
+ const scope = manifest.scope ?? targetScope;
775
797
  const readUpdateSet = options.readUpdateSet || (async () => null);
776
798
  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);
799
+ if (manifest.scope !== undefined && targetScope && manifest.scope !== targetScope) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
800
+ let beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
801
+ // A legacy ProcessFlow read can expose the preceding version's timestamp
802
+ // immediately after Autosave. Never substitute the newer Table timestamp into
803
+ // an older definition: reread the whole document, then let safeEdit check it.
804
+ const recordUpdated = stringValue(target.flow.sys_updated_on);
805
+ if (recordUpdated && beforeDefinition.updated !== recordUpdated) {
806
+ beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
807
+ if (beforeDefinition.updated !== recordUpdated) throw new Error(`Flow ${flowId} definition is stale relative to its record; no edit session or content mutation attempted`);
808
+ }
779
809
  const snapshots = [];
780
810
  const triggerUpdates = [];
781
811
  const actionUpdates = [];
782
812
  const changedActionInputs = new Map();
783
813
  const actionTargets = new Map();
814
+ const changes = [];
784
815
  let triggerUpdate;
785
816
  if (manifest.trigger !== undefined) {
786
817
  const currentTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
@@ -790,6 +821,9 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
790
821
  : await readTableDescriptor(sdk, manifest.trigger.table ?? currentTable);
791
822
  triggerUpdate = updatedTrigger(beforeDefinition, manifest, descriptor).update;
792
823
  triggerUpdates.push(triggerUpdate);
824
+ for (const input of ['table', 'condition']) {
825
+ if (manifest.trigger[input] !== undefined) changes.push({ component: 'trigger', input, before: inputRawValue(inputList(currentTrigger).find(field => field.name === input)), after: manifest.trigger[input] });
826
+ }
793
827
  }
794
828
  const existingActions = findExistingActions(beforeDefinition);
795
829
  for (const [index, requested] of (manifest.actions || []).entries()) {
@@ -797,78 +831,110 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
797
831
  const update = buildActionUpdate(existing, requested, index);
798
832
  actionUpdates.push(update);
799
833
  const uiId = stringValue(existing.uiUniqueIdentifier || existing.ui_unique_identifier);
834
+ if (actionTargets.has(uiId)) throw new Error(`Duplicate action update for order ${requested.order}; combine input changes in one action entry`);
800
835
  actionTargets.set(uiId, existing);
801
836
  changedActionInputs.set(uiId, new Set(Object.keys(requested.inputs)));
837
+ 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
838
  }
803
839
  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}`);
840
+ if (options.preview) return { preview: true, flow: { id: flowId, name: target.flowName, status: 'draft', active: false }, changes, lifecycle: { published: false, executed: false } };
841
+
842
+ return withFlowSafeEdit(sdk, instance, flowId, beforeDefinition.updated, async () => {
843
+ let completedStage = 'validated';
844
+ try {
845
+ const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
846
+ method: 'POST',
847
+ body: JSON.stringify({ query: buildFlowUpdateMutation({ flowId, triggerUpdates, actionUpdates }), variables: {} }),
848
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
849
+ assertGraphQLSuccess(patchResponse, 'Flow trigger/action update');
850
+ completedStage = 'patch_acknowledged';
851
+ const processflow = await readProcessFlow(sdk, instance, flowId, scope);
852
+ // Only requested values may differ. Preserve array order and opaque fields.
853
+ for (const field of ['flowVariables', 'flowLogicInstances', 'subFlowInstances', 'flowInputs', 'flowOutputs', 'inputs', 'outputs', 'stages']) {
854
+ if (!isDeepStrictEqual(beforeDefinition[field], processflow[field])) throw new Error(`Flow ${flowId} changed omitted ${field}; preservation failed`);
829
855
  }
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`);
856
+ if (!triggerUpdate && !isDeepStrictEqual(beforeDefinition.triggerInstances, processflow.triggerInstances)) throw new Error(`Flow ${flowId} changed omitted triggerInstances`);
857
+ const maskActions = definition => findExistingActions(definition).map(action => {
858
+ const copy = clone(action);
859
+ const changed = changedActionInputs.get(stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier));
860
+ for (const [index, input] of inputList(copy).entries()) if (changed?.has(input.name)) inputList(copy)[index] = maskInputValue(input);
861
+ return copy;
862
+ });
863
+ if (!isDeepStrictEqual(maskActions(beforeDefinition), maskActions(processflow))) throw new Error(`Flow ${flowId} changed omitted action fields; preservation failed`);
864
+ const readbackTrigger = findArray(processflow, 'triggerInstances')[0];
865
+ if (triggerUpdate) {
866
+ if (!readbackTrigger || findId(readbackTrigger, ['sysId', 'sys_id', 'id', 'triggerId']) !== triggerUpdate.id) throw new Error(`Flow ${flowId} trigger update was not readable after mutation`);
867
+ const beforeTrigger = findArray(beforeDefinition, 'triggerInstances')[0];
868
+ const preserveBefore = { ...beforeTrigger, inputs: undefined, metadata: undefined };
869
+ const preserveAfter = { ...readbackTrigger, inputs: undefined, metadata: undefined };
870
+ if (JSON.stringify(preserveBefore) !== JSON.stringify(preserveAfter)) throw new Error(`Flow ${flowId} trigger readback changed fields omitted by the manifest`);
871
+ const readbackInputs = inputList(readbackTrigger);
872
+ const beforeInputs = inputList(beforeTrigger);
873
+ if (readbackInputs.length !== beforeInputs.length) throw new Error(`Flow ${flowId} trigger input count changed during update`);
874
+ const requestedInputs = new Set();
875
+ if (manifest.trigger.table !== undefined) requestedInputs.add('table');
876
+ if (manifest.trigger.condition !== undefined) requestedInputs.add('condition');
877
+ const maskTriggerInputs = inputs => inputs.map(input => {
878
+ if (!requestedInputs.has(input.name)) return input;
879
+ const copy = maskInputValue(input);
880
+ delete copy.displayField;
881
+ // Live table saves materialize only this absent display label as ''.
882
+ // Do not collapse named values, defaults or arbitrary empty fields.
883
+ if (copy.parameter?.type_label === '') delete copy.parameter.type_label;
884
+ return copy;
885
+ });
886
+ if (!isDeepStrictEqual(maskTriggerInputs(beforeInputs), maskTriggerInputs(readbackInputs))) throw new Error(`Flow ${flowId} trigger input preservation failed`);
887
+ for (const beforeInput of beforeInputs) {
888
+ const afterInput = readbackInputs.find(input => input.name === beforeInput.name);
889
+ if (!afterInput) throw new Error(`Flow ${flowId} trigger lost input ${beforeInput.name} during update`);
890
+ if (!requestedInputs.has(beforeInput.name) && JSON.stringify(beforeInput) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} trigger changed omitted input ${beforeInput.name}`);
891
+ }
892
+ 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`);
893
+ if (manifest.trigger.condition !== undefined) {
894
+ 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`);
895
+ assertPredicateMetadataMatches(readbackTrigger.metadata, manifest.trigger.condition, `Flow ${flowId} trigger`);
896
+ const beforeMetadata = stripPredicates(parseMaybeJson(beforeTrigger.metadata, {}));
897
+ const afterMetadata = stripPredicates(parseMaybeJson(readbackTrigger.metadata, {}));
898
+ if (JSON.stringify(beforeMetadata) !== JSON.stringify(afterMetadata)) throw new Error(`Flow ${flowId} trigger metadata changed fields omitted by the manifest`);
899
+ } else if (JSON.stringify(parseMaybeJson(beforeTrigger.metadata, {})) !== JSON.stringify(parseMaybeJson(readbackTrigger.metadata, {}))) {
900
+ throw new Error(`Flow ${flowId} trigger metadata changed when the condition was omitted`);
901
+ }
839
902
  }
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`);
903
+ const readbackActions = findExistingActions(processflow);
904
+ for (const update of actionUpdates) {
905
+ const uiId = stringValue(update.uiUniqueIdentifier);
906
+ const readback = readbackActions.find(action => stringValue(action.uiUniqueIdentifier || action.ui_unique_identifier) === uiId);
907
+ if (!readback) throw new Error(`Flow ${flowId} Action ${uiId} was not readable after mutation`);
908
+ const before = actionTargets.get(uiId);
909
+ const changed = changedActionInputs.get(uiId) || new Set();
910
+ for (const input of inputList(before)) {
911
+ const afterInput = inputList(readback).find(candidate => candidate.name === input.name);
912
+ if (!afterInput) throw new Error(`Flow ${flowId} Action ${uiId} lost input ${input.name} during update`);
913
+ if (!changed.has(input.name) && JSON.stringify(input) !== JSON.stringify(afterInput)) throw new Error(`Flow ${flowId} Action ${uiId} changed omitted input ${input.name}`);
914
+ 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`);
915
+ }
853
916
  }
917
+ completedStage = 'definition_verified';
918
+ const { version } = await mutationContext('autosave_version', readUpdateSet, snapshots, () => createVerifiedFlowVersion(sdk, {
919
+ instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
920
+ }), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
921
+ completedStage = 'autosave_verified';
922
+ const selectedUpdateSet = await readUpdateSet();
923
+ const updateSetCapture = await readUpdateSetCapture(sdk, selectedUpdateSet, flowId);
924
+ return {
925
+ flow: { id: flowId, name: target.flowName, status: 'draft', active: false },
926
+ changes,
927
+ processflow,
928
+ definition_readback: { available: true, status: 'read_back', flow_id: flowId },
929
+ version,
930
+ update_set_capture: updateSetCapture,
931
+ update_set_mutations: snapshots,
932
+ lifecycle: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
933
+ };
934
+ } catch (error) {
935
+ 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
936
  }
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 });
871
- }
937
+ });
872
938
  }
873
939
 
874
940
  export async function readCurrentUpdateSet(sdk) {
@@ -370,7 +370,7 @@ 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
  }
@@ -396,7 +396,7 @@ export async function guidedFlowEdit(argv, app) {
396
396
  const actionType = rawField(action.actionTypeSysId || action.action_type_sys_id || action.actionType?.sys_id || action.actionType?.sysId);
397
397
  let inputSchema = { ...field, type: field.type || field.parameter?.type };
398
398
  if (!inputSchema.type) {
399
- const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.scope), { requireTypes: true });
399
+ const { schema } = await readActionDefinition(app.sdk, app.getEffectiveInstance(), actionType, rawField(record.sys_scope) || rawField(record.scope), { requireTypes: true });
400
400
  inputSchema = schema.find(candidate => candidate.name === field.name);
401
401
  if (!inputSchema) throw errUsage(`Saved action input ${field.name} is absent from its provider schema`);
402
402
  }
@@ -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
+ }