@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.9-dev.62",
3
+ "version": "4.3.9-dev.63",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 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.',
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
- const identifier = await pickFlow(argv, app, !hasDefinitionData(argv) ? 'active=false^status=draft' : '');
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 draft flow ${identifier}; unspecified components and inputs are preserved`);
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'} draft flow "${result.flow.name}" (${result.flow.id}) without activation`,
372
- breadcrumbs: [{ action: 'show', cmd: `jsn flows show ${result.flow.id}`, description: 'Inspect the updated draft flow' }],
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
  })
@@ -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: 'sys_id,name,sys_scope,scope,active,status,type,sys_updated_on' }));
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 resolveDraftFlow(sdk, identifier) {
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
- 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.`);
580
- return { flow, flowId, flowName };
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 resolveDraftFlow(sdk, identifier);
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
- let beforeDefinition = await readProcessFlow(sdk, instance, flowId, scope);
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
- 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`);
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 { 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 () => {
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: { id: flowId, name: target.flowName, status: 'draft', active: false },
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: { published: false, supported: ['draft', 'autosave'], deferred: ['publish/activation', 'Flow Logic', 'subflows', 'flow variables', 'error handling'] },
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
- if (rawField(record.active) === true || rawField(record.active) === 'true' || rawField(record.status) !== 'draft') throw errUsage('Guided flow edit requires an inactive Draft');
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 draft flow fields', choices: [
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') {