@waron97/prbot 3.2.1 → 3.4.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.
@@ -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.
@@ -154,7 +154,14 @@ async function autoLayout(structure) {
154
154
  targets: [e.target],
155
155
  sourcePort,
156
156
  labels: e.name
157
- ? [{ id: `${e.id}__lbl`, text: e.name, width: Math.min(e.name.length * 6, 140), height: 14 }]
157
+ ? [
158
+ {
159
+ id: `${e.id}__lbl`,
160
+ text: e.name,
161
+ width: Math.min(e.name.length * 6, 140),
162
+ height: 14,
163
+ },
164
+ ]
158
165
  : [],
159
166
  layoutOptions: {
160
167
  'elk.layered.priority.straightness': isHappy ? '10' : 1,