@jacebenson/jsn 4.3.9-dev.62 → 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 +13 -6
- package/src/flow-create.js +204 -15
- package/src/flow-designer-interactive.js +7 -2
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,7 +341,7 @@ export function flowsCmd(wrap) {
|
|
|
341
341
|
.command({
|
|
342
342
|
command: 'update [identifier]',
|
|
343
343
|
aliases: ['edit'],
|
|
344
|
-
describe: 'Save partial edits to
|
|
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' })
|
|
@@ -350,7 +350,10 @@ export function flowsCmd(wrap) {
|
|
|
350
350
|
handler: wrap(async (argv, app) => {
|
|
351
351
|
app.requireInstance();
|
|
352
352
|
const dataFile = argv.dataFile ?? argv['data-file'];
|
|
353
|
-
|
|
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);
|
|
354
357
|
if (identifier === undefined) return;
|
|
355
358
|
argv = { ...argv, identifier };
|
|
356
359
|
let manifest;
|
|
@@ -361,15 +364,19 @@ export function flowsCmd(wrap) {
|
|
|
361
364
|
} catch (error) {
|
|
362
365
|
throw new Error(`Invalid flow update manifest JSON: ${error.message}`, { cause: error });
|
|
363
366
|
}
|
|
364
|
-
if (!argv.preview) 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.`);
|
|
365
368
|
const result = await updateFlowFromManifest(app.sdk, app.getEffectiveInstance(), argv.identifier, manifest, {
|
|
366
369
|
preview: argv.preview,
|
|
367
370
|
readUpdateSet: () => readCurrentUpdateSet(app.sdk),
|
|
368
371
|
});
|
|
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
|
+
: '';
|
|
370
377
|
return app.ok({ ...result, _formatted: diff }, {
|
|
371
|
-
summary: `${result.preview ? 'Preview for' : 'Updated'}
|
|
372
|
-
breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated
|
|
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' }],
|
|
373
380
|
});
|
|
374
381
|
}),
|
|
375
382
|
})
|
package/src/flow-create.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
-
import { withFlowSafeEdit } from './flow-safe-edit.js';
|
|
3
|
+
import { flowSafeEditRequest, withFlowSafeEdit } from './flow-safe-edit.js';
|
|
4
4
|
import { assertSafeExactMatch } from './helpers.js';
|
|
5
5
|
import { createVerifiedFlowVersion } from './flow-versioning.js';
|
|
6
6
|
import {
|
|
@@ -21,6 +21,20 @@ const FLOW_ENDPOINT = '/api/now/processflow/flow';
|
|
|
21
21
|
const GRAPHQL_ENDPOINT = '/api/now/graphql';
|
|
22
22
|
const PROTECTED_FLOW_ID = 'c215135293bf03d087b0f14fdd03d652';
|
|
23
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
|
+
}
|
|
24
38
|
|
|
25
39
|
function isSysId(value) {
|
|
26
40
|
return typeof value === 'string' && /^[0-9a-f]{32}$/i.test(value);
|
|
@@ -566,18 +580,123 @@ function readFlowRecord(sdk, identifier) {
|
|
|
566
580
|
if (!exactIdentifier) throw new Error('Flow identifier must be an exact sys_id or name');
|
|
567
581
|
assertSafeExactMatch(exactIdentifier);
|
|
568
582
|
const query = isSysId(exactIdentifier) ? `sys_id=${exactIdentifier}` : `name=${exactIdentifier}`;
|
|
569
|
-
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(', ');
|
|
570
600
|
}
|
|
571
601
|
|
|
572
|
-
async function
|
|
602
|
+
async function resolveFlowForUpdate(sdk, identifier) {
|
|
573
603
|
const rows = await readFlowRecord(sdk, identifier);
|
|
574
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`);
|
|
575
605
|
const flow = rows[0];
|
|
576
606
|
const flowId = idValue(flow.sys_id);
|
|
577
607
|
const flowName = stringValue(flow.name);
|
|
578
608
|
if (flowId === PROTECTED_FLOW_ID || flowName === PROTECTED_FLOW_NAME) throw new Error(`Protected flow ${PROTECTED_FLOW_NAME} cannot be updated`);
|
|
579
|
-
|
|
580
|
-
|
|
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 };
|
|
581
700
|
}
|
|
582
701
|
|
|
583
702
|
function updatedTrigger(flow, manifest, descriptor) {
|
|
@@ -790,22 +909,36 @@ export async function createFlowFromManifest(sdk, instance, inputManifest, optio
|
|
|
790
909
|
|
|
791
910
|
export async function updateFlowFromManifest(sdk, instance, identifier, inputManifest, options = {}) {
|
|
792
911
|
const manifest = validateFlowUpdateManifest(inputManifest);
|
|
793
|
-
const target = await
|
|
912
|
+
const target = await resolveFlowForUpdate(sdk, identifier);
|
|
794
913
|
const flowId = target.flowId;
|
|
914
|
+
const lifecycle = target.lifecycle;
|
|
915
|
+
const published = !lifecycle.draft;
|
|
795
916
|
const targetScope = stringValue(target.flow.sys_scope) || stringValue(target.flow.scope);
|
|
796
917
|
const scope = manifest.scope ?? targetScope;
|
|
797
918
|
const readUpdateSet = options.readUpdateSet || (async () => null);
|
|
798
919
|
if (!scope) throw new Error(`Flow ${flowId} has no transaction scope`);
|
|
799
920
|
if (manifest.scope !== undefined && targetScope && manifest.scope !== targetScope) throw new Error(`Flow ${flowId} scope does not match the update manifest`);
|
|
800
|
-
|
|
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();
|
|
801
930
|
// A legacy ProcessFlow read can expose the preceding version's timestamp
|
|
802
931
|
// immediately after Autosave. Never substitute the newer Table timestamp into
|
|
803
932
|
// an older definition: reread the whole document, then let safeEdit check it.
|
|
804
933
|
const recordUpdated = stringValue(target.flow.sys_updated_on);
|
|
805
934
|
if (recordUpdated && beforeDefinition.updated !== recordUpdated) {
|
|
806
|
-
|
|
807
|
-
|
|
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`);
|
|
808
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`);
|
|
809
942
|
const snapshots = [];
|
|
810
943
|
const triggerUpdates = [];
|
|
811
944
|
const actionUpdates = [];
|
|
@@ -837,9 +970,24 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
837
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 });
|
|
838
971
|
}
|
|
839
972
|
if (triggerUpdates.length === 0 && actionUpdates.length === 0) throw new Error(`Flow ${flowId} update has no supported changes`);
|
|
840
|
-
if (options.preview) return {
|
|
841
|
-
|
|
842
|
-
|
|
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 () => {
|
|
843
991
|
let completedStage = 'validated';
|
|
844
992
|
try {
|
|
845
993
|
const patchResponse = await mutationContext('trigger_and_actions_update', readUpdateSet, snapshots, () => sdk.request(`${instance}${GRAPHQL_ENDPOINT}`, {
|
|
@@ -919,22 +1067,63 @@ export async function updateFlowFromManifest(sdk, instance, identifier, inputMan
|
|
|
919
1067
|
instance, flowId, scope, type: 'Autosave', annotation: 'JSN flow update',
|
|
920
1068
|
}), updateSet => readUpdateSetCapture(sdk, updateSet, flowId));
|
|
921
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
|
+
}
|
|
922
1084
|
const selectedUpdateSet = await readUpdateSet();
|
|
923
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;
|
|
924
1090
|
return {
|
|
925
|
-
flow: {
|
|
1091
|
+
flow: {
|
|
1092
|
+
id: flowId,
|
|
1093
|
+
name: target.flowName,
|
|
1094
|
+
status: finalRecord?.status || lifecycle.status,
|
|
1095
|
+
active: finalRecord ? finalRecord.active === 'true' : lifecycle.active,
|
|
1096
|
+
},
|
|
926
1097
|
changes,
|
|
927
1098
|
processflow,
|
|
928
1099
|
definition_readback: { available: true, status: 'read_back', flow_id: flowId },
|
|
929
1100
|
version,
|
|
1101
|
+
...(runtime ? { runtime_preservation: runtime } : {}),
|
|
930
1102
|
update_set_capture: updateSetCapture,
|
|
931
1103
|
update_set_mutations: snapshots,
|
|
932
|
-
lifecycle: {
|
|
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
|
+
},
|
|
933
1112
|
};
|
|
934
1113
|
} catch (error) {
|
|
935
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 });
|
|
936
1115
|
}
|
|
937
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' };
|
|
1125
|
+
}
|
|
1126
|
+
return outcome;
|
|
938
1127
|
}
|
|
939
1128
|
|
|
940
1129
|
export async function readCurrentUpdateSet(sdk) {
|
|
@@ -377,9 +377,14 @@ export async function savedFlowDefinition(app, identifier) {
|
|
|
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') {
|