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