@waron97/prbot 3.2.1 → 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.
@@ -42,13 +42,16 @@ program
42
42
 
43
43
  program
44
44
  .command('clone')
45
- .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
+ )
46
48
  .option('--phase', 'Clone a phase (select a workflow)')
47
49
  .option('--mfa', 'Clone a Model Function Access record')
48
50
  .option('--pb', 'Clone a process-builder wizard')
51
+ .option('--lrp', 'Clone a long-running process')
49
52
  .option('--id <id>', 'Record ID to clone (phase/mfa)')
50
- .option('--name <document_id>', 'Process-builder document_id to clone (with --pb)')
51
- .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)')
52
55
  .action((opts) =>
53
56
  clone(opts).catch((err) => {
54
57
  console.error(`Error: ${err.message}`);
@@ -68,9 +71,9 @@ program
68
71
 
69
72
  program
70
73
  .command('push')
71
- .description('Push local changes to RIP / Process Builder (backs up remote first)')
72
- .option('--publish', 'Auto-publish pushed process-builder wizards')
73
- .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)')
74
77
  .action((opts) =>
75
78
  push(opts).catch((err) => {
76
79
  console.error(`Error: ${err.message}`);
@@ -108,25 +111,29 @@ program
108
111
  })
109
112
  );
110
113
 
111
- // ---- pb: local editing helpers for a cloned process-builder wizard ----
114
+ // ---- pb: local editing helpers for a cloned process-builder wizard or LRP ----
112
115
  const die = (err) => {
113
116
  console.error(`Error: ${err.message}`);
114
117
  process.exit(1);
115
118
  };
116
119
  const pb = program
117
120
  .command('pb')
118
- .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
+ );
119
124
 
120
125
  pb.command('format')
121
126
  .description('Auto-lay-out the diagram (left→right) and rewrite geometry')
122
- .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)')
123
128
  .action((opts) => pbFormat(opts).catch(die));
124
129
 
125
130
  pb.command('add')
126
131
  .description('Add a node (scaffolds script/page); stub geometry, run format after')
127
132
  .requiredOption(
128
133
  '--type <type>',
129
- '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)'
130
137
  )
131
138
  .option('--name <name>', 'Node name')
132
139
  .option('--parent <id>', 'Place inside this subProcess/transaction')
@@ -136,13 +143,13 @@ pb.command('add')
136
143
  'exactly one edge must already run --from → --to)'
137
144
  )
138
145
  .option('--to <id>', 'Insert between two already-connected nodes: target id (requires --from)')
139
- .option('--pb <document_id>', 'Target wizard')
146
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
140
147
  .action((opts) => pbAdd(opts).catch(die));
141
148
 
142
149
  pb.command('rm')
143
150
  .description('Remove a node, its edges, and its script/page files')
144
151
  .requiredOption('--id <id>', 'Node id to remove')
145
- .option('--pb <document_id>', 'Target wizard')
152
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
146
153
  .action((opts) => pbRemove(opts).catch(die));
147
154
 
148
155
  pb.command('connect')
@@ -153,7 +160,7 @@ pb.command('connect')
153
160
  .option('--condition <expr>', 'Condition expression, e.g. ${isAlive}')
154
161
  .option('--condition-type <type>', 'xsi:type for the condition (default tFormalExpression)')
155
162
  .option('--default', 'Mark this as the source gateway default flow')
156
- .option('--pb <document_id>', 'Target wizard')
163
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
157
164
  .action((opts) => pbConnect(opts).catch(die));
158
165
 
159
166
  pb.command('disconnect')
@@ -161,7 +168,7 @@ pb.command('disconnect')
161
168
  .option('--id <id>', 'Edge id to remove')
162
169
  .option('--from <id>', 'Source node id')
163
170
  .option('--to <id>', 'Target node id')
164
- .option('--pb <document_id>', 'Target wizard')
171
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
165
172
  .action((opts) => pbDisconnect(opts).catch(die));
166
173
 
167
174
  pb.command('set-default')
@@ -169,23 +176,25 @@ pb.command('set-default')
169
176
  .option('--id <id>', 'Edge id to mark default')
170
177
  .option('--from <id>', 'Source gateway id')
171
178
  .option('--to <id>', 'Target node id')
172
- .option('--pb <document_id>', 'Target wizard')
179
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
173
180
  .action((opts) => pbSetDefault(opts).catch(die));
174
181
 
175
182
  pb.command('lint')
176
- .description('Check diagram for structural issues (edge names, incoming-edge rules, gateway rules)')
177
- .option('--pb <document_id>', 'Target wizard')
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')
178
187
  .action((opts) => pbLint(opts).catch(die));
179
188
 
180
189
  pb.command('ls')
181
190
  .description('List nodes and edges (discover ids without reading the YAML)')
182
- .option('--pb <document_id>', 'Target wizard')
191
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
183
192
  .action((opts) => pbList(opts).catch(die));
184
193
 
185
194
  pb.command('preview')
186
195
  .description('Render the diagram to an SVG (dev check of format output)')
187
196
  .option('--out <file>', 'Output path (default <project>/preview.svg)')
188
- .option('--pb <document_id>', 'Target wizard')
197
+ .option('--pb <document_id_or_name>', 'Target wizard/LRP')
189
198
  .action((opts) => pbPreview(opts).catch(die));
190
199
 
191
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 };
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { stringify as yamlStringify } from 'yaml';
16
16
  import { pad, toSlug } from './pbProject.js';
17
+ import { SCRIPT_TEMPLATE } from './pbScriptTemplate.js';
17
18
 
18
19
  // Fixed element sizes (measured across all fixtures). Containers are sized by
19
20
  // the layout engine; we seed a small default so a stubbed clone stays valid.
@@ -27,6 +28,12 @@ const SIZE = {
27
28
  userTask: [84, 84],
28
29
  subProcess: [350, 200],
29
30
  transaction: [350, 200],
31
+ // LRP-only (measured across the real LRP corpus):
32
+ intermediateCatchEvent: [36, 36],
33
+ intermediateThrowEvent: [36, 36],
34
+ callActivity: [84, 84],
35
+ parallelGateway: [50, 50],
36
+ eventBasedGateway: [50, 50],
30
37
  };
31
38
 
32
39
  // BPMN id prefixes by node type (mirrors the upstream naming convention).
@@ -40,6 +47,11 @@ const PREFIX = {
40
47
  userTask: 'UserTask',
41
48
  subProcess: 'SubProcess',
42
49
  transaction: 'Transaction',
50
+ intermediateCatchEvent: 'IntermediateCatchEvent',
51
+ intermediateThrowEvent: 'IntermediateThrowEvent',
52
+ callActivity: 'CallActivity',
53
+ parallelGateway: 'ParallelGateway',
54
+ eventBasedGateway: 'EventBasedGateway',
43
55
  };
44
56
 
45
57
  const CONTAINER = new Set(['subProcess', 'transaction']);
@@ -126,7 +138,7 @@ function nextScriptSeq(existingScriptFiles) {
126
138
 
127
139
  // ---------- add ----------
128
140
 
129
- // Add a node. For scriptTask, scaffolds an empty script file and refs it. For
141
+ // Add a node. For scriptTask, scaffolds a templated script file and refs it. For
130
142
  // userTask, scaffolds a minimal page file + a manifest page entry (guid=null →
131
143
  // push will create it upstream). Container nodes start empty + expanded.
132
144
  function addNode(structure, manifest, opts, ctx = {}) {
@@ -144,7 +156,7 @@ function addNode(structure, manifest, opts, ctx = {}) {
144
156
  if (type === 'scriptTask') {
145
157
  const seq = nextScriptSeq(ctx.existingScripts);
146
158
  const file = `scripts/${pad(seq)}_${toSlug(name || id)}.js`;
147
- writes[file] = '';
159
+ writes[file] = SCRIPT_TEMPLATE;
148
160
  node.script = file;
149
161
  } else if (type === 'userTask') {
150
162
  const formKey = toSlug(name || id);
@@ -434,19 +446,24 @@ function lintEdgeNames(structure) {
434
446
  for (const e of outs) {
435
447
  if (e.id === def) continue;
436
448
  if (!e.name) {
437
- issues.push(
438
- `${n.id} → ${e.target} (${e.id}): non-default flow has no name.`
439
- );
449
+ issues.push(`${n.id} → ${e.target} (${e.id}): non-default flow has no name.`);
440
450
  }
441
451
  }
442
452
  });
443
453
  return issues;
444
454
  }
445
455
 
456
+ // Gateway types that legitimately fan multiple flows into one node (a "join").
457
+ // parallelGateway/eventBasedGateway joins are common in the real LRP corpus
458
+ // (61 occurrences) — only exclusiveGateway's merge semantics need a lint rule.
459
+ const JOIN_CAPABLE_GATEWAYS = new Set(['exclusiveGateway', 'parallelGateway', 'eventBasedGateway']);
460
+
446
461
  // Flag incoming-edge violations:
447
- // - Only exclusiveGateway may have >1 incoming flows.
462
+ // - Only a gateway may have >1 incoming flows (plain tasks/events are 1-in).
448
463
  // - An exclusiveGateway may not have both >1 incoming AND >1 outgoing (pick one
449
- // direction for merging or splitting, not both at once).
464
+ // direction for merging or splitting, not both at once) — parallel/event-based
465
+ // gateways may legitimately fork-then-join across the diagram, so this
466
+ // specific rule stays scoped to exclusiveGateway.
450
467
  function lintIncomingEdges(structure) {
451
468
  const inCount = {};
452
469
  const outCount = {};
@@ -461,9 +478,9 @@ function lintIncomingEdges(structure) {
461
478
  eachNode(structure.nodes, null, (n) => {
462
479
  const inc = inCount[n.id] || 0;
463
480
  const out = outCount[n.id] || 0;
464
- if (inc > 1 && n.type !== 'exclusiveGateway') {
481
+ if (inc > 1 && !JOIN_CAPABLE_GATEWAYS.has(n.type)) {
465
482
  issues.push(
466
- `${n.id} (${n.name || n.type}): only exclusiveGateway may have multiple incoming flows (has ${inc}).`
483
+ `${n.id} (${n.name || n.type}): only a gateway may have multiple incoming flows (has ${inc}).`
467
484
  );
468
485
  }
469
486
  if (n.type === 'exclusiveGateway' && inc > 1 && out > 1) {
@@ -477,7 +494,11 @@ function lintIncomingEdges(structure) {
477
494
 
478
495
  // Run all lint rules and return combined issues.
479
496
  function lintAll(structure) {
480
- return [...lintGateways(structure), ...lintEdgeNames(structure), ...lintIncomingEdges(structure)];
497
+ return [
498
+ ...lintGateways(structure),
499
+ ...lintEdgeNames(structure),
500
+ ...lintIncomingEdges(structure),
501
+ ];
481
502
  }
482
503
 
483
504
  // Remove an edge by id, or by --from/--to pair.