@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 +1 -1
- package/src/commands/flows.js +7 -4
- package/src/flow-create.js +137 -71
- package/src/flow-designer-interactive.js +2 -2
- package/src/flow-safe-edit.js +48 -0
package/package.json
CHANGED
package/src/commands/flows.js
CHANGED
|
@@ -341,10 +341,11 @@ export function flowsCmd(wrap) {
|
|
|
341
341
|
.command({
|
|
342
342
|
command: 'update [identifier]',
|
|
343
343
|
aliases: ['edit'],
|
|
344
|
-
describe: '
|
|
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
|
-
|
|
368
|
-
|
|
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
|
}),
|
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
|
}
|
|
@@ -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
|
+
}
|