@jacebenson/jsn 4.3.8 → 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/README.md +11 -417
- package/package.json +4 -2
- package/skills/servicenow/SKILL.md +54 -0
- package/src/cli.js +4 -0
- package/src/commands/actions.js +89 -2
- package/src/commands/catalog.js +2 -2
- package/src/commands/customizations.js +82 -0
- package/src/commands/flows.js +28 -6
- package/src/commands/skipped-updates.js +102 -0
- package/src/commands/updatesets.js +91 -31
- package/src/context.js +16 -25
- package/src/customizations-report.js +158 -0
- package/src/customizations.js +517 -0
- package/src/flow-create.js +137 -71
- package/src/flow-designer-interactive.js +2 -2
- package/src/flow-publish.js +15 -3
- package/src/flow-safe-edit.js +48 -0
- package/src/impact-analysis.js +67 -0
- package/src/sdk.js +33 -2
- package/src/skipped-update-provider.js +45 -0
- package/src/skipped-updates.js +177 -0
package/src/flow-create.js
CHANGED
|
@@ -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()
|
|
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
|
|
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
|
|
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 &&
|
|
778
|
-
|
|
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
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
const
|
|
818
|
-
|
|
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 (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
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
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/flow-publish.js
CHANGED
|
@@ -80,9 +80,21 @@ export async function publishFlows(sdk, flowIDs = [], actionIDs = []) {
|
|
|
80
80
|
/** Publish one flow through the ProcessFlow snapshot/version lifecycle. */
|
|
81
81
|
export async function publishFlowLifecycle(sdk, instance, flowId) {
|
|
82
82
|
const flowRecord = sdk.get ? await sdk.get('sys_hub_flow', flowId) : null;
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
83
|
+
const flowPath = `${instance}${PROCESSFLOW_PATH}/${encodeURIComponent(flowId)}`;
|
|
84
|
+
// sys_hub_flow.scope is often omitted by the Table API for newly created
|
|
85
|
+
// flows. The ProcessFlow definition carries the application scope needed by
|
|
86
|
+
// its snapshot and version endpoints, so read it before constructing either.
|
|
87
|
+
let scope = str(flowRecord, 'scope');
|
|
88
|
+
let definitionResponse;
|
|
89
|
+
if (!scope) {
|
|
90
|
+
definitionResponse = await sdk.request(flowPath, { method: 'GET' });
|
|
91
|
+
const initialDefinition = definitionResponse?.result?.data ?? definitionResponse?.data?.result?.data ?? definitionResponse?.data ?? definitionResponse?.result ?? definitionResponse;
|
|
92
|
+
scope = str(initialDefinition, 'scope');
|
|
93
|
+
if (!scope) throw new Error(`Flow ${flowId} has no transaction scope; refusing to publish`);
|
|
94
|
+
}
|
|
95
|
+
if (!definitionResponse) {
|
|
96
|
+
definitionResponse = await sdk.request(`${flowPath}?sysparm_transaction_scope=${encodeURIComponent(scope)}`, { method: 'GET' });
|
|
97
|
+
}
|
|
86
98
|
const definition = definitionResponse?.result?.data ?? definitionResponse?.data?.result?.data ?? definitionResponse?.data ?? definitionResponse?.result ?? definitionResponse;
|
|
87
99
|
if (!definition || typeof definition !== 'object') throw new Error(`Flow definition could not be read for ${flowId}`);
|
|
88
100
|
const definitionScope = str(definition, 'scope');
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Reusable, provider-independent impact analysis. No CLI, prompts or writes.
|
|
2
|
+
const raw = value => String(value?.value ?? value ?? '');
|
|
3
|
+
const BOOKKEEPING_FIELDS = new Set(['sys_id', 'sys_created_on', 'sys_created_by', 'sys_updated_on',
|
|
4
|
+
'sys_updated_by', 'sys_mod_count', 'sys_recorded_at', 'sys_update_name']);
|
|
5
|
+
export const isImpactField = field => !BOOKKEEPING_FIELDS.has(field);
|
|
6
|
+
const decode = value => value.replace(/</g, '<').replace(/>/g, '>')
|
|
7
|
+
.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, '&');
|
|
8
|
+
|
|
9
|
+
// Deliberately accepts only the observed flat record_update payload shape.
|
|
10
|
+
// Unknown XML constructs are evidence gaps, never silently discarded changes.
|
|
11
|
+
export function baselineFields(payload, artifact) {
|
|
12
|
+
if (!payload || /<!DOCTYPE|<!ENTITY/i.test(payload)) return null;
|
|
13
|
+
const table = artifact.table;
|
|
14
|
+
if (!/^[a-z][a-z0-9_]*$/.test(table || '')) return null;
|
|
15
|
+
const outer = payload.replace(/^\s*<\?xml[^?]*\?>\s*/, '').trim();
|
|
16
|
+
const match = outer.match(/^<record_update\s+table="([a-z0-9_]+)">\s*<([a-z0-9_]+)\s+action="INSERT_OR_UPDATE">([\s\S]*)<\/\2>\s*<\/record_update>$/);
|
|
17
|
+
if (!match || match[1] !== table || match[2] !== table) return null;
|
|
18
|
+
const fields = {};
|
|
19
|
+
let rest = match[3];
|
|
20
|
+
while (rest.trim()) {
|
|
21
|
+
const field = rest.match(/^\s*<([a-z0-9_]+)(?:\s+[^<>]*?)?(?:\s*\/>|>([\s\S]*?)<\/\1>)/);
|
|
22
|
+
if (!field || Object.hasOwn(fields, field[1])) return null;
|
|
23
|
+
let value = field[2] ?? '';
|
|
24
|
+
if (value.startsWith('<![CDATA[') && value.endsWith(']]>')) value = value.slice(9, -3);
|
|
25
|
+
else {
|
|
26
|
+
if (/[<>]/.test(value) || /&(?!lt;|gt;|quot;|apos;|amp;)/.test(value)) return null;
|
|
27
|
+
value = decode(value);
|
|
28
|
+
}
|
|
29
|
+
fields[field[1]] = value;
|
|
30
|
+
rest = rest.slice(field[0].length);
|
|
31
|
+
}
|
|
32
|
+
if (fields.sys_id !== artifact.sys_id) return null;
|
|
33
|
+
return fields;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function analyzeImpact({ artifact, current, baseline, dependencies = [], unknown = [] }) {
|
|
37
|
+
const fields = baselineFields(baseline?.payload, artifact);
|
|
38
|
+
const gaps = [...unknown, 'Dynamic script references, transitive dependencies and runtime outcomes are unknown; no execution testing was performed.'];
|
|
39
|
+
const changes = [];
|
|
40
|
+
if (!fields) gaps.push('The exact base payload is unavailable or its XML shape/identity is unsupported.');
|
|
41
|
+
if (!current) gaps.push('The current artifact is unavailable.');
|
|
42
|
+
if (fields && current) {
|
|
43
|
+
for (const field of Object.keys(fields).sort()) {
|
|
44
|
+
if (!isImpactField(field)) continue;
|
|
45
|
+
if (!Object.hasOwn(current, field)) {
|
|
46
|
+
gaps.push(`Current field ${field} was not returned; its revert impact is unknown.`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (raw(current[field]) !== fields[field]) {
|
|
50
|
+
changes.push({ field, current: raw(current[field]), base: fields[field],
|
|
51
|
+
effect: field === 'script' ? 'Replace the executable script with the base script; runtime behavior is unverified.'
|
|
52
|
+
: field === 'active' ? `Set activation to ${fields[field]}; execution eligibility changes.`
|
|
53
|
+
: `Replace ${field} with the base value; behavior beyond this field change is unknown.` });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
gaps.push('Fields absent from the base payload and provider-generated normalization are unknown.');
|
|
57
|
+
}
|
|
58
|
+
return { artifact, evidence: { baseline_version: baseline?.sys_id || null,
|
|
59
|
+
baseline_source: baseline?.source || null, baseline_source_table: baseline?.source_table || null },
|
|
60
|
+
direct_behavior: { status: fields && current ? 'field_comparison' : 'unknown',
|
|
61
|
+
summary: fields && current ? `${changes.length} observed field change(s) on return to base; this is not a runtime simulation.` : 'Unknown without both current artifact and a supported exact base payload.' },
|
|
62
|
+
changes, known_dependencies: dependencies, unknown: gaps,
|
|
63
|
+
recommendation: { action: 'retain', basis: changes.length
|
|
64
|
+
? 'Preserve the customization pending validation of the observed changes and unknown dependencies.'
|
|
65
|
+
: 'Preserve the customization until the unknown runtime and dependency effects are validated.',
|
|
66
|
+
automatic: false } };
|
|
67
|
+
}
|
package/src/sdk.js
CHANGED
|
@@ -4,6 +4,7 @@ import { gunzipSync } from 'node:zlib';
|
|
|
4
4
|
import { errAuth, errAPI, errNetwork } from './errors.js';
|
|
5
5
|
import { getStringField } from './helpers.js';
|
|
6
6
|
import { getAction, getStepTypes, createAction, updateAction, testAction } from './processflow-actions.js';
|
|
7
|
+
import { readSkippedBaseline, revertSkippedUpdate } from './skipped-update-provider.js';
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMEOUT = 30000;
|
|
9
10
|
|
|
@@ -94,7 +95,8 @@ export class SDKClient {
|
|
|
94
95
|
}
|
|
95
96
|
|
|
96
97
|
async request(endpoint, opts = {}) {
|
|
97
|
-
const { timeout = this.timeout,
|
|
98
|
+
const { timeout = this.timeout, readOnly = false, touchLastSeen, ...requestOptions } = opts;
|
|
99
|
+
if (readOnly && (requestOptions.method || 'GET').toUpperCase() !== 'GET') throw new Error('Read-only SDK requests require GET');
|
|
98
100
|
const controller = new AbortController();
|
|
99
101
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
100
102
|
|
|
@@ -110,6 +112,8 @@ export class SDKClient {
|
|
|
110
112
|
if (this.domain) {
|
|
111
113
|
req.headers.set('X-Now-Domain', this.domain);
|
|
112
114
|
}
|
|
115
|
+
// readOnly guards the data request, not standard authentication bootstrap.
|
|
116
|
+
// OAuth refresh and session/GCK negotiation are not instance-record writes.
|
|
113
117
|
await this._setAuth(req);
|
|
114
118
|
|
|
115
119
|
const resp = await fetch(req);
|
|
@@ -125,7 +129,10 @@ export class SDKClient {
|
|
|
125
129
|
|
|
126
130
|
// Stamp last_seen on successful API call
|
|
127
131
|
try {
|
|
128
|
-
|
|
132
|
+
// readOnly suppresses the credential last-seen write unconditionally: a
|
|
133
|
+
// caller passing touchLastSeen cannot override it (GLOSSARY contract).
|
|
134
|
+
const shouldTouch = readOnly ? false : (touchLastSeen ?? true);
|
|
135
|
+
if (shouldTouch && this.authProvider && typeof this.authProvider.touchLastSeen === 'function') {
|
|
129
136
|
this.authProvider.touchLastSeen(this.baseURL);
|
|
130
137
|
}
|
|
131
138
|
} catch { /* non-fatal */ }
|
|
@@ -197,6 +204,14 @@ export class SDKClient {
|
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
|
207
|
+
async getSkippedUpdateBaseline(sysID) {
|
|
208
|
+
return readSkippedBaseline(this, sysID);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async revertSkippedUpdate(sysID, modCount) {
|
|
212
|
+
return revertSkippedUpdate(this, sysID, modCount);
|
|
213
|
+
}
|
|
214
|
+
|
|
200
215
|
async list(table, params = {}, opts = {}) {
|
|
201
216
|
const query = new URLSearchParams(params).toString();
|
|
202
217
|
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
@@ -204,6 +219,22 @@ export class SDKClient {
|
|
|
204
219
|
return result?.result || [];
|
|
205
220
|
}
|
|
206
221
|
|
|
222
|
+
// Strict Table API seam. `list` stays best-effort for legacy callers, but a
|
|
223
|
+
// 2xx response without a result array is a malformed envelope, not an empty
|
|
224
|
+
// table. Capture paths use this so an unexpected body becomes an unavailable
|
|
225
|
+
// source instead of a complete-looking empty inventory.
|
|
226
|
+
async listStrict(table, params = {}, opts = {}) {
|
|
227
|
+
const query = new URLSearchParams(params).toString();
|
|
228
|
+
const endpoint = `${this.baseURL}/api/now/table/${table}${query ? '?' + query : ''}`;
|
|
229
|
+
const response = await this.request(endpoint, { method: 'GET', ...opts });
|
|
230
|
+
if (!response || typeof response !== 'object' || !Array.isArray(response.result)) {
|
|
231
|
+
const error = new Error(`Malformed table response for ${table}: expected a JSON envelope with a result array`);
|
|
232
|
+
error.code = 'malformed_response';
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return response.result;
|
|
236
|
+
}
|
|
237
|
+
|
|
207
238
|
async get(table, sysID) {
|
|
208
239
|
const endpoint = `${this.baseURL}/api/now/table/${table}/${sysID}`;
|
|
209
240
|
const result = await this.request(endpoint, { method: 'GET' });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { errUsage } from './errors.js';
|
|
2
|
+
|
|
3
|
+
function validateId(id) {
|
|
4
|
+
if (!/^[0-9a-fA-F]{32}$/.test(id || '')) throw errUsage('A literal 32-hex skipped finding sys_id is required.');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Contract observed in RevertUpdateVersionAjax.revertToBaseSystem, PDI Australia.
|
|
8
|
+
// Keep read and write separate. No Table API state update can implement revert.
|
|
9
|
+
export async function readSkippedBaseline(sdk, id) {
|
|
10
|
+
validateId(id);
|
|
11
|
+
const output = await sdk.executeScript(`(function() {
|
|
12
|
+
var record = new GlideRecordSecure('sys_upgrade_history_log');
|
|
13
|
+
if (!record.get('${id}') || !record.canRead()) throw new Error('Finding unavailable');
|
|
14
|
+
var name = record.getValue('file_name');
|
|
15
|
+
var storeAppID = GlideappUpdateVersion.getStoreAppID(record);
|
|
16
|
+
var baseline = storeAppID != null
|
|
17
|
+
? GlideappUpdateVersion.getVersion(name, storeAppID, 'sys_store_app', null)
|
|
18
|
+
: GlideappUpdateVersion.getVersion(name, record.upgrade_history, 'sys_upgrade_history', null);
|
|
19
|
+
if (!baseline || !baseline.isValidRecord()) throw new Error('No base system version found');
|
|
20
|
+
gs.print('JSN_SKIPPED_BASE:' + JSON.stringify({sys_id: baseline.getUniqueValue(),
|
|
21
|
+
payload: baseline.getValue('payload'), source: baseline.getValue('source'),
|
|
22
|
+
source_table: baseline.getValue('source_table')}));
|
|
23
|
+
})();`, 'global');
|
|
24
|
+
const match = output.match(/JSN_SKIPPED_BASE:(\{[^\r\n]*\})/);
|
|
25
|
+
if (!match) throw errUsage('Base version inspection returned no verifiable result.');
|
|
26
|
+
try { return JSON.parse(match[1]); } catch { throw errUsage('Base version inspection returned invalid JSON.'); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function revertSkippedUpdate(sdk, id, modCount) {
|
|
30
|
+
validateId(id);
|
|
31
|
+
if (!/^\d+$/.test(modCount || '')) throw errUsage('Finding update count is required for safe revert.');
|
|
32
|
+
const output = await sdk.executeScript(`(function() {
|
|
33
|
+
if (!gs.hasRole('admin')) throw new Error('Admin role required');
|
|
34
|
+
var record = new GlideRecordSecure('sys_upgrade_history_log');
|
|
35
|
+
if (!record.get('${id}') || !record.canWrite()) throw new Error('Finding unavailable or not writable');
|
|
36
|
+
if (record.getValue('disposition') !== '4' ||
|
|
37
|
+
['', 'not_reviewed'].indexOf(record.getValue('resolution_status') || '') < 0 ||
|
|
38
|
+
record.getValue('sys_mod_count') !== '${modCount}') throw new Error('Finding changed or is not eligible');
|
|
39
|
+
new RevertUpdateVersionAjax().revertToBaseSystem('${id}');
|
|
40
|
+
gs.print('JSN_SKIPPED_REVERT:dispatched');
|
|
41
|
+
})();`, 'global');
|
|
42
|
+
if (!output.includes('JSN_SKIPPED_REVERT:dispatched')) throw errUsage('Revert dispatch outcome is unknown; inspect the finding before retrying.');
|
|
43
|
+
// The processor returns no success boolean. Caller must verify disposition and
|
|
44
|
+
// resolution by GET; dispatch alone must never be reported as success.
|
|
45
|
+
}
|