@waron97/prbot 3.2.0 → 3.3.0

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.
@@ -5,11 +5,13 @@ import { getToken } from '../../lib/auth.js';
5
5
  import { updateMfa, updatePhase } from '../lib/api.js';
6
6
  import { computeChecksum } from '../lib/checksum.js';
7
7
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
8
+ import { fetchUpstream } from '../lib/lrpApi.js';
8
9
  import { getProcess } from '../lib/pbApi.js';
9
- import { localChecksum } from '../lib/pbProject.js';
10
+ import { localChecksum, remoteChecksumPb } from '../lib/pbProject.js';
10
11
  import { projectReader } from '../lib/pbWorkspace.js';
11
12
  import { fileExists, readCodeFile } from '../lib/workspace.js';
12
13
  import { fetchRemoteCode, selectEntries } from './pull.js';
14
+ import { deploy, pushLrpEntry } from './pushLrp.js';
13
15
  import { publish, pushPbEntry } from './pushPb.js';
14
16
 
15
17
  const BACKUP_DIR = '.backup';
@@ -46,11 +48,16 @@ async function push(opts = {}) {
46
48
  (e) => e.object_type === 'phase' || e.object_type === 'mfa'
47
49
  );
48
50
  const hasPb = config.workspace.some((e) => e.object_type === 'process_builder');
51
+ const hasLrp = config.workspace.some((e) => e.object_type === 'long_running_process');
49
52
  const ripUrl = process.env.RIP_URL;
50
53
  if (hasCode && !ripUrl)
51
54
  throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
52
55
  if (hasPb && !process.env.PB_URL)
53
56
  throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
57
+ if (hasLrp && !process.env.IMPORTEXPORT_URL)
58
+ throw new Error(
59
+ 'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
60
+ );
54
61
 
55
62
  console.log('Fetching remote state...');
56
63
  const token = await getToken();
@@ -66,17 +73,37 @@ async function push(opts = {}) {
66
73
  upstreamMap.set(e.guid, null);
67
74
  }
68
75
  }
76
+ const lrpUpstreamMap = new Map();
77
+ for (const e of config.workspace.filter((x) => x.object_type === 'long_running_process')) {
78
+ try {
79
+ lrpUpstreamMap.set(e.name, await fetchUpstream(token, e.name));
80
+ } catch {
81
+ lrpUpstreamMap.set(e.name, null);
82
+ }
83
+ }
69
84
 
70
85
  // Classify every entry to a status badge — concern: overwriting remote work.
71
86
  const classified = config.workspace.map((entry) => {
72
87
  if (entry.object_type === 'process_builder') {
73
88
  const upstream = upstreamMap.get(entry.guid) ?? null;
74
- const local = localChecksum(projectReader(entry.path));
75
- const localChanged = entry.checksum_at_pull !== local;
76
- const remoteChanged = !upstream || upstream.updated_date !== entry.updated_date;
89
+ const localSemantic = localChecksum(projectReader(entry.path));
90
+ const remoteSemantic = upstream ? remoteChecksumPb(upstream) : null;
91
+ const pullChecksum = entry.checksum_at_pull;
77
92
  let status;
78
- if (!localChanged && !remoteChanged) status = 'unchanged';
79
- else if (!remoteChanged) status = 'fast-forward';
93
+ if (localSemantic === remoteSemantic) status = 'unchanged';
94
+ else if (pullChecksum === remoteSemantic) status = 'fast-forward';
95
+ else status = 'conflict';
96
+ return { ...entry, upstream, status };
97
+ }
98
+
99
+ if (entry.object_type === 'long_running_process') {
100
+ const upstream = lrpUpstreamMap.get(entry.name) ?? null;
101
+ const localSemantic = localChecksum(projectReader(entry.path));
102
+ const remoteSemantic = upstream ? remoteChecksumPb(upstream.payload) : null;
103
+ const pullChecksum = entry.checksum_at_pull;
104
+ let status;
105
+ if (localSemantic === remoteSemantic) status = 'unchanged';
106
+ else if (pullChecksum === remoteSemantic) status = 'fast-forward';
80
107
  else status = 'conflict';
81
108
  return { ...entry, upstream, status };
82
109
  }
@@ -88,8 +115,7 @@ async function push(opts = {}) {
88
115
  const localChecksumVal = computeChecksum(localCode ?? '');
89
116
  const pullChecksum = entry.checksum_at_pull;
90
117
  let status;
91
- if (pullChecksum === localChecksumVal && pullChecksum === remoteChecksum)
92
- status = 'unchanged';
118
+ if (localChecksumVal === remoteChecksum) status = 'unchanged';
93
119
  else if (pullChecksum === remoteChecksum) status = 'fast-forward';
94
120
  else status = 'conflict';
95
121
  return { ...entry, remoteCode, localCode, status };
@@ -109,10 +135,14 @@ async function push(opts = {}) {
109
135
 
110
136
  const backupTs = new Date().toISOString().replace(/:/g, '-').replace('T', '_').slice(0, 19);
111
137
 
112
- // Back up remote code for phase/mfa before overwriting (pb backs up its own
113
- // full upstream payload inside pushPbEntry).
138
+ // Back up remote code for phase/mfa before overwriting (pb/lrp back up
139
+ // their own full upstream payload inside pushPbEntry/pushLrpEntry).
114
140
  for (const entry of selected) {
115
- if (entry.object_type !== 'process_builder' && entry.remoteCode != null) {
141
+ if (
142
+ entry.object_type !== 'process_builder' &&
143
+ entry.object_type !== 'long_running_process' &&
144
+ entry.remoteCode != null
145
+ ) {
116
146
  const backupPath = join(BACKUP_DIR, backupTs, entry.path);
117
147
  mkdirSync(dirname(backupPath), { recursive: true });
118
148
  writeFileSync(backupPath, (entry.remoteCode ?? '').trim() + '\n', 'utf-8');
@@ -120,15 +150,15 @@ async function push(opts = {}) {
120
150
  }
121
151
 
122
152
  const pushedPb = [];
153
+ const pushedLrp = [];
123
154
  let pushed = 0;
124
155
  for (const entry of selected) {
125
- const idx = config.workspace.findIndex(
126
- (e) =>
127
- e.object_type === entry.object_type &&
128
- (entry.object_type === 'process_builder'
129
- ? e.guid === entry.guid
130
- : e.id === entry.id)
131
- );
156
+ const idx = config.workspace.findIndex((e) => {
157
+ if (e.object_type !== entry.object_type) return false;
158
+ if (entry.object_type === 'process_builder') return e.guid === entry.guid;
159
+ if (entry.object_type === 'long_running_process') return e.name === entry.name;
160
+ return e.id === entry.id;
161
+ });
132
162
 
133
163
  if (entry.object_type === 'process_builder') {
134
164
  const res = await pushPbEntry(token, entry, BACKUP_DIR, backupTs);
@@ -145,6 +175,19 @@ async function push(opts = {}) {
145
175
  config.workspace[idx].status = res.newStatus || 'draft';
146
176
  }
147
177
  pushedPb.push({ entry, idx });
178
+ } else if (entry.object_type === 'long_running_process') {
179
+ const res = await pushLrpEntry(token, entry, BACKUP_DIR, backupTs);
180
+ console.log(` ${entry.name} → saved`);
181
+ if (idx !== -1) {
182
+ config.workspace[idx].checksum_at_pull = res.newChecksum;
183
+ config.workspace[idx].tenant_id = res.newRow.tenantId;
184
+ config.workspace[idx].description = res.newRow.description;
185
+ config.workspace[idx].version = res.newRow.version;
186
+ config.workspace[idx].status = res.newRow.status;
187
+ }
188
+ // deployId is the fresh post-save id (re-resolved by name in
189
+ // pushLrpEntry); the pre-save id is dead once the save returns.
190
+ pushedLrp.push({ entry, idx, deployId: res.deployId });
148
191
  } else {
149
192
  const code = entry.localCode ?? '';
150
193
  if (entry.object_type === 'phase') await updatePhase(token, ripUrl, entry.id, code);
@@ -156,10 +199,13 @@ async function push(opts = {}) {
156
199
 
157
200
  console.log(`\nRemote backups written to ${BACKUP_DIR}/${backupTs}/`);
158
201
 
159
- // Publish step for pushed wizards (now in "draft").
202
+ // Publish/deploy step for pushed wizards and LRPs.
160
203
  if (pushedPb.length) {
161
204
  await handlePublish(token, pushedPb, config, opts);
162
205
  }
206
+ if (pushedLrp.length) {
207
+ await handleDeploy(token, pushedLrp, config, opts);
208
+ }
163
209
 
164
210
  writeConfig(config);
165
211
  console.log(`\nPushed ${pushed} record(s).`);
@@ -188,4 +234,27 @@ async function handlePublish(token, pushedPb, config, opts) {
188
234
  }
189
235
  }
190
236
 
237
+ async function handleDeploy(token, pushedLrp, config, opts) {
238
+ for (const { entry, idx, deployId } of pushedLrp) {
239
+ let doDeploy;
240
+ if (opts.skipPublish) doDeploy = false;
241
+ else if (opts.publish) doDeploy = true;
242
+ else {
243
+ ({ doDeploy } = await inquirer.prompt([
244
+ {
245
+ type: 'confirm',
246
+ name: 'doDeploy',
247
+ message: `Deploy "${entry.name}" now?`,
248
+ default: false,
249
+ },
250
+ ]));
251
+ }
252
+ if (doDeploy) {
253
+ await deploy(token, deployId);
254
+ if (idx !== -1) config.workspace[idx].status = 'deployed';
255
+ console.log(` deployed ${entry.name}`);
256
+ }
257
+ }
258
+ }
259
+
191
260
  export { push };
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, writeFileSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { deployLrp, resolveLrpByName, saveLrp } from '../lib/lrpApi.js';
4
+ import { localChecksum, recompose } from '../lib/pbProject.js';
5
+ import { projectReader } from '../lib/pbWorkspace.js';
6
+
7
+ // Push one LRP entry: back up upstream, re-resolve the CURRENT id/tenantId by
8
+ // name (never trust a stored/stale id — it changes on every save/version),
9
+ // and PATCH the recomposed XML back. Returns a summary; deploy is a separate
10
+ // step (see deploy()), mirroring PB's push-then-publish split.
11
+ //
12
+ // The save bumps the version and mints a NEW id, so the pre-save id is stale
13
+ // the moment saveLrp returns. Both the deploy target and the post-save
14
+ // version/status must come from a fresh resolve-by-name AFTER the save — never
15
+ // from the pre-save row or the PATCH response (which does not echo the new id).
16
+ async function pushLrpEntry(token, entry, backupDir, backupTs) {
17
+ const read = projectReader(entry.path);
18
+ const localPayload = recompose(read);
19
+ const upstream = entry.upstream; // { row, payload } from push.js's classification
20
+
21
+ if (upstream) {
22
+ const backupPath = join(backupDir, backupTs, entry.path, 'upstream.xml');
23
+ mkdirSync(dirname(backupPath), { recursive: true });
24
+ writeFileSync(backupPath, upstream.payload.built_page, 'utf-8');
25
+ }
26
+
27
+ // Re-resolve immediately before saving — the row captured during
28
+ // classification may be stale if something else saved in between.
29
+ const row = await resolveLrpByName(token, entry.name);
30
+ const description = upstream?.row?.description ?? entry.description ?? '';
31
+ const saved = await saveLrp(
32
+ token,
33
+ { ...row, bpmnFileSvg: entry.svg ?? row.bpmnFileSvg },
34
+ localPayload.built_page,
35
+ description
36
+ );
37
+
38
+ // The save minted a new id and bumped the version — re-resolve by name to
39
+ // get the fresh row. deployBpmn must target this new id (the pre-save id is
40
+ // now dead: deploying it 200s but activates nothing), and the workspace
41
+ // entry's version/status must come from here, not the stale pre-save row.
42
+ const deployRow = await resolveLrpByName(token, entry.name);
43
+
44
+ return {
45
+ newChecksum: localChecksum(read),
46
+ newRow: { ...deployRow, description },
47
+ saved,
48
+ deployId: deployRow.id,
49
+ };
50
+ }
51
+
52
+ function deploy(token, id) {
53
+ return deployLrp(token, id);
54
+ }
55
+
56
+ export { pushLrpEntry, deploy };
@@ -10,6 +10,7 @@ import {
10
10
  pbConnect,
11
11
  pbDisconnect,
12
12
  pbFormat,
13
+ pbLint,
13
14
  pbList,
14
15
  pbPreview,
15
16
  pbRemove,
@@ -41,13 +42,16 @@ program
41
42
 
42
43
  program
43
44
  .command('clone')
44
- .description('Clone a phase, MFA, or process-builder wizard into this workspace')
45
+ .description(
46
+ 'Clone a phase, MFA, process-builder wizard, or long-running process into this workspace'
47
+ )
45
48
  .option('--phase', 'Clone a phase (select a workflow)')
46
49
  .option('--mfa', 'Clone a Model Function Access record')
47
50
  .option('--pb', 'Clone a process-builder wizard')
51
+ .option('--lrp', 'Clone a long-running process')
48
52
  .option('--id <id>', 'Record ID to clone (phase/mfa)')
49
- .option('--name <document_id>', 'Process-builder document_id to clone (with --pb)')
50
- .option('--path <path>', 'Destination path (file for MFA, base dir for workflow/pb)')
53
+ .option('--name <name>', 'document_id (--pb) or process name (--lrp) to clone')
54
+ .option('--path <path>', 'Destination path (file for MFA, base dir for workflow/pb/lrp)')
51
55
  .action((opts) =>
52
56
  clone(opts).catch((err) => {
53
57
  console.error(`Error: ${err.message}`);
@@ -67,9 +71,9 @@ program
67
71
 
68
72
  program
69
73
  .command('push')
70
- .description('Push local changes to RIP / Process Builder (backs up remote first)')
71
- .option('--publish', 'Auto-publish pushed process-builder wizards')
72
- .option('--skip-publish', 'Skip publishing pushed wizards (no prompt)')
74
+ .description('Push local changes to RIP / Process Builder / LRP (backs up remote first)')
75
+ .option('--publish', 'Auto-publish pushed wizards and auto-deploy pushed LRPs')
76
+ .option('--skip-publish', 'Skip publishing/deploying pushed wizards and LRPs (no prompt)')
73
77
  .action((opts) =>
74
78
  push(opts).catch((err) => {
75
79
  console.error(`Error: ${err.message}`);
@@ -107,25 +111,29 @@ program
107
111
  })
108
112
  );
109
113
 
110
- // ---- pb: local editing helpers for a cloned process-builder wizard ----
114
+ // ---- pb: local editing helpers for a cloned process-builder wizard or LRP ----
111
115
  const die = (err) => {
112
116
  console.error(`Error: ${err.message}`);
113
117
  process.exit(1);
114
118
  };
115
119
  const pb = program
116
120
  .command('pb')
117
- .description('Edit a cloned process-builder wizard (local; run `pb format` after edits)');
121
+ .description(
122
+ 'Edit a cloned process-builder wizard or long-running process (local; run `pb format` after edits)'
123
+ );
118
124
 
119
125
  pb.command('format')
120
126
  .description('Auto-lay-out the diagram (left→right) and rewrite geometry')
121
- .option('--pb <document_id>', 'Target wizard (else single-entry / fuzzy prompt)')
127
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP (else single-entry / fuzzy prompt)')
122
128
  .action((opts) => pbFormat(opts).catch(die));
123
129
 
124
130
  pb.command('add')
125
131
  .description('Add a node (scaffolds script/page); stub geometry, run format after')
126
132
  .requiredOption(
127
133
  '--type <type>',
128
- 'Node type: scriptTask|serviceTask|userTask|exclusiveGateway|subProcess|transaction|startEvent|endEvent|boundaryEvent'
134
+ 'Node type: scriptTask|serviceTask|userTask|exclusiveGateway|subProcess|transaction|' +
135
+ 'startEvent|endEvent|boundaryEvent|intermediateCatchEvent|intermediateThrowEvent|' +
136
+ 'callActivity|parallelGateway|eventBasedGateway (userTask is process-builder only)'
129
137
  )
130
138
  .option('--name <name>', 'Node name')
131
139
  .option('--parent <id>', 'Place inside this subProcess/transaction')
@@ -135,13 +143,13 @@ pb.command('add')
135
143
  'exactly one edge must already run --from → --to)'
136
144
  )
137
145
  .option('--to <id>', 'Insert between two already-connected nodes: target id (requires --from)')
138
- .option('--pb <document_id>', 'Target wizard')
146
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
139
147
  .action((opts) => pbAdd(opts).catch(die));
140
148
 
141
149
  pb.command('rm')
142
150
  .description('Remove a node, its edges, and its script/page files')
143
151
  .requiredOption('--id <id>', 'Node id to remove')
144
- .option('--pb <document_id>', 'Target wizard')
152
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
145
153
  .action((opts) => pbRemove(opts).catch(die));
146
154
 
147
155
  pb.command('connect')
@@ -152,7 +160,7 @@ pb.command('connect')
152
160
  .option('--condition <expr>', 'Condition expression, e.g. ${isAlive}')
153
161
  .option('--condition-type <type>', 'xsi:type for the condition (default tFormalExpression)')
154
162
  .option('--default', 'Mark this as the source gateway default flow')
155
- .option('--pb <document_id>', 'Target wizard')
163
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
156
164
  .action((opts) => pbConnect(opts).catch(die));
157
165
 
158
166
  pb.command('disconnect')
@@ -160,7 +168,7 @@ pb.command('disconnect')
160
168
  .option('--id <id>', 'Edge id to remove')
161
169
  .option('--from <id>', 'Source node id')
162
170
  .option('--to <id>', 'Target node id')
163
- .option('--pb <document_id>', 'Target wizard')
171
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
164
172
  .action((opts) => pbDisconnect(opts).catch(die));
165
173
 
166
174
  pb.command('set-default')
@@ -168,18 +176,25 @@ pb.command('set-default')
168
176
  .option('--id <id>', 'Edge id to mark default')
169
177
  .option('--from <id>', 'Source gateway id')
170
178
  .option('--to <id>', 'Target node id')
171
- .option('--pb <document_id>', 'Target wizard')
179
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
172
180
  .action((opts) => pbSetDefault(opts).catch(die));
173
181
 
182
+ pb.command('lint')
183
+ .description(
184
+ 'Check diagram for structural issues (edge names, incoming-edge rules, gateway rules)'
185
+ )
186
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
187
+ .action((opts) => pbLint(opts).catch(die));
188
+
174
189
  pb.command('ls')
175
190
  .description('List nodes and edges (discover ids without reading the YAML)')
176
- .option('--pb <document_id>', 'Target wizard')
191
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
177
192
  .action((opts) => pbList(opts).catch(die));
178
193
 
179
194
  pb.command('preview')
180
195
  .description('Render the diagram to an SVG (dev check of format output)')
181
196
  .option('--out <file>', 'Output path (default <project>/preview.svg)')
182
- .option('--pb <document_id>', 'Target wizard')
197
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
183
198
  .action((opts) => pbPreview(opts).catch(die));
184
199
 
185
200
  program.parse();
@@ -0,0 +1,153 @@
1
+ // Symphony long-running-process (LRP) API client. Base = protocol+host of
2
+ // IMPORTEXPORT_URL (the tabulator/deployBpmn endpoints live on the Symphony
3
+ // host itself, not under the import/export path). Auth uses the same
4
+ // Keycloak bearer as everything else (getToken()).
5
+ //
6
+ // Unlike PBs, LRPs have no stable guid: the tabulator `id` changes on every
7
+ // save/version bump (confirmed live — a re-fetched id differs from a
8
+ // previously captured one for the same process). The process `name` is the
9
+ // only stable identifier, so every write re-resolves the current id/tenantId
10
+ // by name immediately before acting on it.
11
+
12
+ import fetch from 'node-fetch';
13
+
14
+ function getSymphonyBase() {
15
+ const url = process.env.IMPORTEXPORT_URL;
16
+ if (!url) throw new Error('IMPORTEXPORT_URL is not configured. Run `prbot init`.');
17
+ const parsed = new URL(url);
18
+ return `${parsed.protocol}//${parsed.host}`;
19
+ }
20
+
21
+ // List/search processes by name (server-side `like` filter when nameFilter is
22
+ // set; unfiltered page otherwise). Returns the richer per-row fields the save
23
+ // body needs (tenantId, svg), not just id/name.
24
+ async function listLrps(token, nameFilter, signal) {
25
+ const base = getSymphonyBase();
26
+ const size = nameFilter ? 20 : 12;
27
+ const params = encodeURIComponent(JSON.stringify({ page: 1, size, sorters: [], filters: [] }));
28
+ const otherfilters = encodeURIComponent(
29
+ JSON.stringify([
30
+ { field: 'id', type: '=', value: null },
31
+ { field: 'name', type: 'like', value: nameFilter ?? null },
32
+ { field: 'tenantId', type: '=', value: null },
33
+ { field: 'latestVersion', type: '=', value: true },
34
+ ])
35
+ );
36
+ const othersort = encodeURIComponent(
37
+ JSON.stringify({ field: 'lastModifiedDate', dir: 'desc' })
38
+ );
39
+ const url = `${base}/symphony/restInfo/ajax/tabulator?params=${params}&connector=SymphBpmnFileTabCon&otherfilters=${otherfilters}&card=true&othersort=${othersort}`;
40
+
41
+ const res = await fetch(url, {
42
+ headers: { accept: 'application/json', Authorization: `Bearer ${token}` },
43
+ signal,
44
+ });
45
+ if (!res.ok) throw new Error(`LRP list failed with ${res.status}: ${await res.text()}`);
46
+ const json = await res.json();
47
+
48
+ const rows = [];
49
+ for (const row of json.data || []) {
50
+ for (let i = 1; i <= 4; i++) {
51
+ const cell = row[`cellContent${i}`];
52
+ if (cell && cell.id && cell.name) {
53
+ rows.push({
54
+ id: String(cell.id),
55
+ name: cell.name,
56
+ tenantId: cell.tenantId,
57
+ version: cell.version,
58
+ status: cell.status,
59
+ bpmnFileSvg: cell.bpmnFileSvg,
60
+ });
61
+ }
62
+ }
63
+ }
64
+ return rows;
65
+ }
66
+
67
+ // Resolve the current row for a process by its stable name (exact match
68
+ // against the server-side `like` search results) — the id-by-name lookup
69
+ // that replaces PB's guid-based addressing everywhere.
70
+ async function resolveLrpByName(token, name) {
71
+ const rows = await listLrps(token, name);
72
+ const row = rows.find((r) => r.name === name);
73
+ if (!row) throw new Error(`No long-running process found with name "${name}"`);
74
+ return row;
75
+ }
76
+
77
+ // Fetch the full BPMN XML + description for one process by id. The endpoint
78
+ // returns an HTML/JS fragment (not JSON) — the real payload is a base64
79
+ // `doc.value` assignment plus a set of `bpmn_*` hidden inputs.
80
+ async function getLrpXml(token, id) {
81
+ const base = getSymphonyBase();
82
+ const url = `${base}/symphony/restInfo/ajax/tabulator/id/${id}?connector=SymphBpmnFileTabCon&modelroot=/management/development/edit`;
83
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
84
+ if (!res.ok) throw new Error(`LRP detail fetch failed with ${res.status}: ${await res.text()}`);
85
+ const text = await res.text();
86
+
87
+ const docMatch = text.match(/doc\.value\s*=\s*'([^']+)'/);
88
+ if (!docMatch) throw new Error('Could not find doc.value in LRP detail response');
89
+ const filenameMatch = text.match(/filename\.value\s*=\s*'([^']+)'/);
90
+ if (!filenameMatch) throw new Error('Could not find filename.value in LRP detail response');
91
+ const descMatch = text.match(/name="bpmn_description"\s+value="([^"]*)"/);
92
+
93
+ return {
94
+ xml: Buffer.from(docMatch[1], 'base64').toString('utf-8'),
95
+ filename: filenameMatch[1],
96
+ description: descMatch ? descMatch[1] : '',
97
+ };
98
+ }
99
+
100
+ // Save (PATCH) the wizard's BPMN back to Symphony. `row` must carry the
101
+ // CURRENT id/tenantId (re-resolved by name immediately before this call —
102
+ // see resolveLrpByName) and `bpmnFileSvg` (the last-known diagram thumbnail,
103
+ // echoed back unchanged per the agreed approach — it's display-only, the
104
+ // server deploys from the XML, not the SVG). `newVersion:false` saves the
105
+ // process in place rather than creating a new version.
106
+ async function saveLrp(token, row, xml, description = '') {
107
+ const base = getSymphonyBase();
108
+ const url = `${base}/symphony/restInfo/ajax/tabulator/${row.id}?connector=SymphBpmnFileTabCon`;
109
+ const body = {
110
+ id: row.id,
111
+ tenantId: row.tenantId,
112
+ newVersion: false,
113
+ description,
114
+ name: row.name,
115
+ bpmnFile: Buffer.from(xml, 'utf-8').toString('base64'),
116
+ bpmnFileSvg: row.bpmnFileSvg ?? '',
117
+ oldTenantId: row.tenantId,
118
+ };
119
+ const res = await fetch(url, {
120
+ method: 'PATCH',
121
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
122
+ body: JSON.stringify(body),
123
+ });
124
+ if (!res.ok) throw new Error(`LRP save failed with ${res.status}: ${await res.text()}`);
125
+ const responseText = await res.text();
126
+ return responseText ? JSON.parse(responseText) : null;
127
+ }
128
+
129
+ // Resolve-by-name + fetch detail in one call — the shape both pull's
130
+ // classification step and pullLrpEntry/pushLrpEntry need: the current row
131
+ // (id/tenantId/svg, re-resolved fresh — never trust a stale one) plus a
132
+ // decompose-ready payload.
133
+ async function fetchUpstream(token, name) {
134
+ const row = await resolveLrpByName(token, name);
135
+ const { xml, description } = await getLrpXml(token, row.id);
136
+ return { row: { ...row, description }, payload: { built_page: xml } };
137
+ }
138
+
139
+ // Deploy a saved process so live consumers see the latest version.
140
+ async function deployLrp(token, id) {
141
+ const base = getSymphonyBase();
142
+ const url = `${base}/symphony/restInfo/ajax/deployBpmn`;
143
+ const res = await fetch(url, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
146
+ body: JSON.stringify({ id }),
147
+ });
148
+ if (!res.ok) throw new Error(`LRP deploy failed with ${res.status}: ${await res.text()}`);
149
+ const responseText = await res.text();
150
+ return responseText ? JSON.parse(responseText) : null;
151
+ }
152
+
153
+ export { listLrps, resolveLrpByName, getLrpXml, saveLrp, deployLrp, fetchUpstream };