@waron97/prbot 3.2.0 → 3.2.1

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/agrippa-pb.md CHANGED
@@ -156,6 +156,7 @@ Node types: `startEvent`, `endEvent`, `boundaryEvent`, `exclusiveGateway`,
156
156
  ```
157
157
  agrippa pb ls # discover node/edge ids
158
158
  agrippa pb add / rm / connect / disconnect / set-default # change the graph
159
+ agrippa pb lint # check for structural issues before handing off
159
160
  # STOP. Do NOT format. Do NOT push. Report back to the human (see Formatting below).
160
161
  ```
161
162
 
@@ -229,20 +230,27 @@ manifest entries. No dangling references left behind.
229
230
  # plain sequence
230
231
  agrippa pb connect --from ScriptTask_a --to ScriptTask_b --pb <document_id>
231
232
 
232
- # gateway branch with a condition
233
+ # named branch (required when the source has 2+ outgoing flows and this is non-default)
233
234
  agrippa pb connect --from ExclusiveGateway_g --to ScriptTask_b \
234
- --condition '${isAlive}' --pb <document_id>
235
+ --name "alive" --condition '${isAlive}' --pb <document_id>
235
236
 
236
- # the gateway's fallback branch
237
+ # the gateway's fallback branch (default needs no name)
237
238
  agrippa pb connect --from ExclusiveGateway_g --to EndEvent_err \
238
239
  --default --pb <document_id>
239
240
  ```
240
241
 
241
242
  A flow's id is printed. Conditions default to `xsi:type="tFormalExpression"`
242
- (override with `--condition-type`). **Gateway rule** (enforced by Activiti, warned by
243
- this command): an `exclusiveGateway` with more than one outgoing flow must have
244
- **exactly one** `--default` flow, and **every other** outgoing flow must carry a
245
- `--condition`. Heed the `!` warnings `connect` prints.
243
+ (override with `--condition-type`). **Rules** (warned by this command and by `pb lint`):
244
+
245
+ - An `exclusiveGateway` with more than one outgoing flow must have **exactly one**
246
+ `--default` flow, and **every other** outgoing flow must carry a `--condition`.
247
+ - Any node with more than one outgoing flow: every **non-default** flow must have a
248
+ `--name` (operators need labels to distinguish branches at runtime).
249
+ - Only `exclusiveGateway` may have more than one **incoming** flow.
250
+ - An `exclusiveGateway` may not have both multiple incoming **and** multiple outgoing
251
+ flows — pick one direction (merging or splitting), not both.
252
+
253
+ Heed the `!` warnings `connect` prints.
246
254
 
247
255
  #### `pb disconnect` — remove a flow
248
256
 
@@ -251,6 +259,22 @@ agrippa pb disconnect --id SequenceFlow_1lso8x0 --pb <document_id>
251
259
  agrippa pb disconnect --from ScriptTask_a --to ScriptTask_b --pb <document_id>
252
260
  ```
253
261
 
262
+ #### `pb lint` — check for structural issues
263
+
264
+ ```bash
265
+ agrippa pb lint --pb <document_id>
266
+ ```
267
+
268
+ Runs all diagram rules and prints any violations. Exits 1 if issues found. Run after
269
+ every batch of structural edits before handing off to a human. Rules checked:
270
+
271
+ - `exclusiveGateway` default/condition rule (2+ outgoing → one default, rest conditioned)
272
+ - Non-default outgoing flows must have a name when the source has 2+ outgoing flows
273
+ - Only `exclusiveGateway` may have multiple incoming flows
274
+ - `exclusiveGateway` may not have both multiple incoming and multiple outgoing flows
275
+
276
+ `pb format` and `pb connect` also surface the same warnings inline.
277
+
254
278
  #### `pb set-default` — change a gateway's default flow
255
279
 
256
280
  ```bash
@@ -324,7 +348,8 @@ graph (`pb ls`) first if so.
324
348
 
325
349
  ```bash
326
350
  agrippa pb add --type exclusiveGateway --name "alive?" --pb W # → ExclusiveGateway_g
327
- agrippa pb connect --from ExclusiveGateway_g --to ScriptTask_ok --condition '${isAlive}' --pb W
351
+ agrippa pb connect --from ExclusiveGateway_g --to ScriptTask_ok \
352
+ --name "alive" --condition '${isAlive}' --pb W
328
353
  agrippa pb connect --from ExclusiveGateway_g --to EndEvent_err --default --pb W
329
354
  ```
330
355
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@waron97/prbot",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -47,19 +47,30 @@ async function diff(targetArg) {
47
47
  const token = await getToken();
48
48
 
49
49
  let diffCount = 0;
50
+ const chunks = [];
50
51
 
51
52
  if (codeEntries.length) {
52
53
  const remoteCodeMap = await fetchRemoteCode(token, process.env.RIP_URL, codeEntries);
53
- diffCount += diffCodeEntries(codeEntries, remoteCodeMap);
54
+ const { diffCount: c, chunks: cs } = diffCodeEntries(codeEntries, remoteCodeMap);
55
+ diffCount += c;
56
+ chunks.push(...cs);
54
57
  }
55
58
 
56
59
  for (const entry of pbEntries) {
57
- if (await diffPbEntry(token, entry)) diffCount++;
60
+ const { hasDiff, chunk } = await diffPbEntry(token, entry);
61
+ if (hasDiff) {
62
+ diffCount++;
63
+ chunks.push(chunk);
64
+ }
58
65
  }
59
66
 
60
67
  if (diffCount === 0) {
61
68
  console.log('No differences found — all tracked files match the remote.');
62
69
  } else {
70
+ const combined = Buffer.concat(chunks);
71
+ const pager = process.env.PAGER || 'less';
72
+ const pagerArgs = pager === 'less' ? ['-R', '-F'] : [];
73
+ spawnSync(pager, pagerArgs, { input: combined, stdio: ['pipe', 'inherit', 'inherit'] });
63
74
  console.log(`\n${diffCount} file(s) differ from remote.`);
64
75
  }
65
76
  }
@@ -68,6 +79,7 @@ async function diff(targetArg) {
68
79
  // hand both sides to `git diff --no-index`.
69
80
  function diffCodeEntries(entries, remoteCodeMap) {
70
81
  let diffCount = 0;
82
+ const chunks = [];
71
83
  const tmpFiles = [];
72
84
  try {
73
85
  for (const entry of entries) {
@@ -86,17 +98,17 @@ function diffCodeEntries(entries, remoteCodeMap) {
86
98
  writeFileSync(tmpPath, (remoteCode ?? '').trim() + '\n', 'utf-8');
87
99
  tmpFiles.push(tmpPath);
88
100
 
89
- console.log(`\n=== ${entry.path} [${entry.name}] ===`);
90
-
91
101
  const result = spawnSync(
92
102
  'git',
93
103
  ['diff', '--no-index', '--color=always', tmpPath, entry.path],
94
- { stdio: ['ignore', 'inherit', 'inherit'] }
104
+ { stdio: ['ignore', 'pipe', 'pipe'] }
95
105
  );
96
106
  // exit code 1 means differences found (normal), 0 means identical, >1 means error
97
107
  if (result.status !== null && result.status > 1) {
98
108
  console.error(`git diff failed for ${entry.path}`);
99
109
  }
110
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] ===\n`);
111
+ chunks.push(Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]));
100
112
  diffCount++;
101
113
  }
102
114
  } finally {
@@ -108,7 +120,7 @@ function diffCodeEntries(entries, remoteCodeMap) {
108
120
  }
109
121
  }
110
122
  }
111
- return diffCount;
123
+ return { diffCount, chunks };
112
124
  }
113
125
 
114
126
  // Process-builder wizard: decompose the upstream payload into a throwaway
@@ -136,18 +148,19 @@ async function diffPbEntry(token, entry) {
136
148
  // and never matches the raw upstream xml byte-for-byte even when nothing
137
149
  // structural changed, so it'd otherwise look like a permanent false diff.
138
150
  if (localChecksum(projectReader(upstreamDir)) === localChecksum(projectReader(localDir)))
139
- return false;
151
+ return { hasDiff: false };
140
152
 
141
- console.log(`\n=== ${entry.path} [${entry.name}] (process-builder) ===`);
142
153
  const result = spawnSync(
143
154
  'git',
144
155
  ['diff', '--no-index', '--color=always', 'upstream', 'local'],
145
- { cwd: tmpRoot, stdio: ['ignore', 'inherit', 'inherit'] }
156
+ { cwd: tmpRoot, stdio: ['ignore', 'pipe', 'pipe'] }
146
157
  );
147
158
  if (result.status !== null && result.status > 1) {
148
159
  console.error(`git diff failed for ${entry.path}`);
149
160
  }
150
- return true;
161
+ const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (process-builder) ===\n`);
162
+ const chunk = Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]);
163
+ return { hasDiff: true, chunk };
151
164
  } finally {
152
165
  rmSync(tmpRoot, { recursive: true, force: true });
153
166
  }
@@ -25,7 +25,7 @@ import {
25
25
  connect,
26
26
  disconnect,
27
27
  eachNode,
28
- lintGateways,
28
+ lintAll,
29
29
  listGraph,
30
30
  removeNode,
31
31
  setDefault,
@@ -125,9 +125,9 @@ async function pbFormat(opts) {
125
125
  console.log(
126
126
  `Formatted ${dir} (${nodes} node(s) laid out${missing ? `, ${missing} without layout` : ''}).`
127
127
  );
128
- const issues = lintGateways(structure);
128
+ const issues = lintAll(structure);
129
129
  if (issues.length) {
130
- console.warn('Gateway issues (exclusiveGateway default/condition rule):');
130
+ console.warn('Diagram issues:');
131
131
  for (const w of issues) console.warn(` ! ${w}`);
132
132
  }
133
133
  }
@@ -276,4 +276,16 @@ async function pbPreview(opts) {
276
276
  console.log(`Wrote ${out} (${svg.length} bytes).`);
277
277
  }
278
278
 
279
- export { pbFormat, pbAdd, pbRemove, pbConnect, pbDisconnect, pbSetDefault, pbList, pbPreview };
279
+ async function pbLint(opts) {
280
+ const dir = await resolveProjectPath(opts);
281
+ const { structure } = loadProject(dir);
282
+ const issues = lintAll(structure);
283
+ if (!issues.length) {
284
+ console.log('No issues.');
285
+ } else {
286
+ for (const w of issues) console.warn(` ! ${w}`);
287
+ process.exitCode = 1;
288
+ }
289
+ }
290
+
291
+ export { pbFormat, pbAdd, pbRemove, pbConnect, pbDisconnect, pbSetDefault, pbList, pbPreview, pbLint };
@@ -5,7 +5,7 @@ import { describeWorkflow, getPhasesByIds, getPhasesByWorkflow, listMfas } from
5
5
  import { computeChecksum } from '../lib/checksum.js';
6
6
  import { loadEffectiveEnv, readConfig, writeConfig } from '../lib/config.js';
7
7
  import { getProcess } from '../lib/pbApi.js';
8
- import { localChecksum } from '../lib/pbProject.js';
8
+ import { localChecksum, remoteChecksumPb } from '../lib/pbProject.js';
9
9
  import { projectReader } from '../lib/pbWorkspace.js';
10
10
  import {
11
11
  fileExists,
@@ -63,13 +63,13 @@ async function pull() {
63
63
  const localCode = readCodeFile(entry.path);
64
64
 
65
65
  const remoteChecksum = computeChecksum(remoteCode ?? '');
66
- const localChecksum = computeChecksum(localCode ?? '');
66
+ const localChecksumVal = computeChecksum(localCode ?? '');
67
67
  const pullChecksum = entry.checksum_at_pull;
68
68
 
69
69
  let status;
70
- if (pullChecksum === localChecksum && pullChecksum === remoteChecksum) {
70
+ if (localChecksumVal === remoteChecksum) {
71
71
  status = 'unchanged';
72
- } else if (pullChecksum === localChecksum) {
72
+ } else if (pullChecksum === localChecksumVal) {
73
73
  status = 'fast-forward';
74
74
  } else {
75
75
  status = 'conflict';
@@ -116,11 +116,10 @@ async function pull() {
116
116
  }
117
117
 
118
118
  // Refresh tracked wizards from upstream. Pull concern (inverted from push):
119
- // overwriting *local* edits. Status per entry from three states —
120
- // checksum_at_pull, current local (recomposed), upstream updated_date:
121
- // unchanged remote not advanced nothing to bring down
122
- // fast-forward remote advanced, local untouched since pull safe overwrite
123
- // conflict remote advanced AND local diverged → overwrite loses local work
119
+ // overwriting *local* edits. Status per entry:
120
+ // unchanged local === remote semantically nothing to do
121
+ // fast-forward remote changed, local untouched since pull safe overwrite
122
+ // conflict local diverged from checksum_at_pull overwrite loses local work
124
123
  async function pullPbEntries(token, config) {
125
124
  const entries = config.workspace.filter((e) => e.object_type === 'process_builder');
126
125
  if (!entries.length) return;
@@ -140,12 +139,13 @@ async function pullPbEntries(token, config) {
140
139
  console.warn(` ${entry.name}: could not fetch upstream, skipping`);
141
140
  continue;
142
141
  }
143
- const local = localChecksum(projectReader(entry.path));
144
- const localChanged = entry.checksum_at_pull !== local;
145
- const remoteChanged = upstream.updated_date !== entry.updated_date;
142
+ const localSemantic = localChecksum(projectReader(entry.path));
143
+ const remoteSemantic = remoteChecksumPb(upstream);
144
+ const pullChecksum = entry.checksum_at_pull;
146
145
  let status;
147
- if (!remoteChanged) status = 'unchanged';
148
- else status = localChanged ? 'conflict' : 'fast-forward';
146
+ if (localSemantic === remoteSemantic) status = 'unchanged';
147
+ else if (pullChecksum === localSemantic) status = 'fast-forward';
148
+ else status = 'conflict';
149
149
  classified.push({ ...entry, upstream, status });
150
150
  }
151
151
 
@@ -6,7 +6,7 @@ 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
8
  import { getProcess } from '../lib/pbApi.js';
9
- import { localChecksum } from '../lib/pbProject.js';
9
+ import { localChecksum, remoteChecksumPb } from '../lib/pbProject.js';
10
10
  import { projectReader } from '../lib/pbWorkspace.js';
11
11
  import { fileExists, readCodeFile } from '../lib/workspace.js';
12
12
  import { fetchRemoteCode, selectEntries } from './pull.js';
@@ -71,12 +71,12 @@ async function push(opts = {}) {
71
71
  const classified = config.workspace.map((entry) => {
72
72
  if (entry.object_type === 'process_builder') {
73
73
  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;
74
+ const localSemantic = localChecksum(projectReader(entry.path));
75
+ const remoteSemantic = upstream ? remoteChecksumPb(upstream) : null;
76
+ const pullChecksum = entry.checksum_at_pull;
77
77
  let status;
78
- if (!localChanged && !remoteChanged) status = 'unchanged';
79
- else if (!remoteChanged) status = 'fast-forward';
78
+ if (localSemantic === remoteSemantic) status = 'unchanged';
79
+ else if (pullChecksum === remoteSemantic) status = 'fast-forward';
80
80
  else status = 'conflict';
81
81
  return { ...entry, upstream, status };
82
82
  }
@@ -88,8 +88,7 @@ async function push(opts = {}) {
88
88
  const localChecksumVal = computeChecksum(localCode ?? '');
89
89
  const pullChecksum = entry.checksum_at_pull;
90
90
  let status;
91
- if (pullChecksum === localChecksumVal && pullChecksum === remoteChecksum)
92
- status = 'unchanged';
91
+ if (localChecksumVal === remoteChecksum) status = 'unchanged';
93
92
  else if (pullChecksum === remoteChecksum) status = 'fast-forward';
94
93
  else status = 'conflict';
95
94
  return { ...entry, remoteCode, localCode, status };
@@ -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,
@@ -171,6 +172,11 @@ pb.command('set-default')
171
172
  .option('--pb <document_id>', 'Target wizard')
172
173
  .action((opts) => pbSetDefault(opts).catch(die));
173
174
 
175
+ pb.command('lint')
176
+ .description('Check diagram for structural issues (edge names, incoming-edge rules, gateway rules)')
177
+ .option('--pb <document_id>', 'Target wizard')
178
+ .action((opts) => pbLint(opts).catch(die));
179
+
174
180
  pb.command('ls')
175
181
  .description('List nodes and edges (discover ids without reading the YAML)')
176
182
  .option('--pb <document_id>', 'Target wizard')
@@ -44,6 +44,20 @@ const PREFIX = {
44
44
 
45
45
  const CONTAINER = new Set(['subProcess', 'transaction']);
46
46
 
47
+ // Default attrs injected at node creation so the editor accepts them without manual edits.
48
+ const DEFAULT_ATTRS = {
49
+ scriptTask: {
50
+ scriptFormat: 'javascript',
51
+ 'activiti:async': 'false',
52
+ 'activiti:exclusive': 'false',
53
+ 'activiti:autoStoreVariables': 'false',
54
+ },
55
+ serviceTask: {
56
+ 'activiti:async': 'false',
57
+ 'activiti:exclusive': 'false',
58
+ },
59
+ };
60
+
47
61
  // Depth-first visit of every node in the nested graph, with its parent node.
48
62
  function eachNode(nodes, parent, fn) {
49
63
  for (const n of nodes || []) {
@@ -161,6 +175,8 @@ function addNode(structure, manifest, opts, ctx = {}) {
161
175
  node.nodes = [];
162
176
  }
163
177
 
178
+ if (DEFAULT_ATTRS[type]) node.attrs = { ...DEFAULT_ATTRS[type] };
179
+
164
180
  if (parentId) {
165
181
  const f = findNode(structure, parentId);
166
182
  if (!f) throw new Error(`parent not found: ${parentId}`);
@@ -334,10 +350,9 @@ function connect(structure, { from, to, name, condition, conditionType, makeDefa
334
350
  warnings.push(`replaced previous default flow ${prev} on ${from}.`);
335
351
  }
336
352
  }
337
- // Re-check the gateway's invariant after the edit.
338
- if (sf.node.type === 'exclusiveGateway') {
339
- warnings.push(...lintGateways(structure).filter((w) => w.startsWith(from)));
340
- }
353
+ // Re-check all structural invariants after the edit, scoped to affected nodes.
354
+ const allIssues = lintAll(structure);
355
+ warnings.push(...allIssues.filter((w) => w.startsWith(from) || w.startsWith(to)));
341
356
 
342
357
  return { writes: {}, deletes: [], result: { id, from, to, warnings } };
343
358
  }
@@ -407,6 +422,64 @@ function lintGateways(structure) {
407
422
  return issues;
408
423
  }
409
424
 
425
+ // Flag non-default outgoing edges that are missing a name when a node has 2+
426
+ // outgoing flows. In Activiti diagrams these labels are mandatory so operators
427
+ // can distinguish branches at runtime.
428
+ function lintEdgeNames(structure) {
429
+ const issues = [];
430
+ eachNode(structure.nodes, null, (n) => {
431
+ const outs = n.edges || [];
432
+ if (outs.length < 2) return;
433
+ const def = n.attrs?.default;
434
+ for (const e of outs) {
435
+ if (e.id === def) continue;
436
+ if (!e.name) {
437
+ issues.push(
438
+ `${n.id} → ${e.target} (${e.id}): non-default flow has no name.`
439
+ );
440
+ }
441
+ }
442
+ });
443
+ return issues;
444
+ }
445
+
446
+ // Flag incoming-edge violations:
447
+ // - Only exclusiveGateway may have >1 incoming flows.
448
+ // - An exclusiveGateway may not have both >1 incoming AND >1 outgoing (pick one
449
+ // direction for merging or splitting, not both at once).
450
+ function lintIncomingEdges(structure) {
451
+ const inCount = {};
452
+ const outCount = {};
453
+ eachNode(structure.nodes, null, (n) => {
454
+ for (const e of n.edges || []) {
455
+ inCount[e.target] = (inCount[e.target] || 0) + 1;
456
+ outCount[n.id] = (outCount[n.id] || 0) + 1;
457
+ }
458
+ });
459
+
460
+ const issues = [];
461
+ eachNode(structure.nodes, null, (n) => {
462
+ const inc = inCount[n.id] || 0;
463
+ const out = outCount[n.id] || 0;
464
+ if (inc > 1 && n.type !== 'exclusiveGateway') {
465
+ issues.push(
466
+ `${n.id} (${n.name || n.type}): only exclusiveGateway may have multiple incoming flows (has ${inc}).`
467
+ );
468
+ }
469
+ if (n.type === 'exclusiveGateway' && inc > 1 && out > 1) {
470
+ issues.push(
471
+ `${n.id} (${n.name || 'gateway'}): exclusiveGateway may not have both multiple incoming (${inc}) and multiple outgoing (${out}) flows.`
472
+ );
473
+ }
474
+ });
475
+ return issues;
476
+ }
477
+
478
+ // Run all lint rules and return combined issues.
479
+ function lintAll(structure) {
480
+ return [...lintGateways(structure), ...lintEdgeNames(structure), ...lintIncomingEdges(structure)];
481
+ }
482
+
410
483
  // Remove an edge by id, or by --from/--to pair.
411
484
  function disconnect(structure, { id, from, to }) {
412
485
  let removed = 0;
@@ -464,4 +537,7 @@ export {
464
537
  setDefault,
465
538
  listGraph,
466
539
  lintGateways,
540
+ lintEdgeNames,
541
+ lintIncomingEdges,
542
+ lintAll,
467
543
  };
@@ -37,7 +37,13 @@ const ROOT_OPTS = {
37
37
  'elk.layered.spacing.edgeEdgeBetweenLayers': '20',
38
38
  'elk.layered.spacing.edgeNodeBetweenLayers': '20',
39
39
  };
40
- const CONTAINER_OPTS = { 'elk.padding': '[top=40.0,left=20.0,bottom=20.0,right=20.0]' };
40
+ const CONTAINER_OPTS = {
41
+ 'elk.padding': '[top=50.0,left=30.0,bottom=50.0,right=30.0]',
42
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
43
+ 'elk.spacing.nodeNode': '80',
44
+ 'elk.spacing.edgeNode': '20',
45
+ 'elk.layered.spacing.edgeNodeBetweenLayers': '20',
46
+ };
41
47
 
42
48
  function sizeOf(n) {
43
49
  if (SIZE[n.type]) return SIZE[n.type];
@@ -147,6 +153,9 @@ async function autoLayout(structure) {
147
153
  sources: [source],
148
154
  targets: [e.target],
149
155
  sourcePort,
156
+ labels: e.name
157
+ ? [{ id: `${e.id}__lbl`, text: e.name, width: Math.min(e.name.length * 6, 140), height: 14 }]
158
+ : [],
150
159
  layoutOptions: {
151
160
  'elk.layered.priority.straightness': isHappy ? '10' : 1,
152
161
  'elk.layered.priority.shortness': isHappy ? '10' : 1,
@@ -237,8 +246,20 @@ async function autoLayout(structure) {
237
246
  height: round(p.height),
238
247
  };
239
248
  for (const e of n.edges || []) {
240
- const wp = waypointsFor(e.id, lcaOffset(n.id, e.target));
249
+ const off = lcaOffset(n.id, e.target);
250
+ const wp = waypointsFor(e.id, off);
241
251
  if (wp) e.waypoints = wp;
252
+ if (e.name) {
253
+ const lbl = elkEdges[e.id]?.labels?.[0];
254
+ if (lbl) {
255
+ e.labelPos = {
256
+ x: round(lbl.x + off.x),
257
+ y: round(lbl.y + off.y),
258
+ width: round(lbl.width),
259
+ height: round(lbl.height),
260
+ };
261
+ }
262
+ }
242
263
  }
243
264
  });
244
265
 
@@ -353,8 +353,9 @@ function boundsAttrs(b) {
353
353
  }
354
354
 
355
355
  // Rebuild <bpmndi> purely from structure.yaml geometry (model graph + geo maps).
356
- // DI element ids are derived (`<id>_di`); the manifest is never consulted. Labels
357
- // are omitted (renderers auto-place them); only positions, sizes, waypoints and
356
+ // DI element ids are derived (`<id>_di`); the manifest is never consulted. Node
357
+ // labels are omitted (renderers auto-place them); edge labels use ELK-computed
358
+ // bounds from geo.labelPos when present — only positions, sizes, waypoints and
358
359
  // subprocess expand-state are authoritative — see compareDiagram.
359
360
  function buildDiagram(model, geo) {
360
361
  if (!geo) return null;
@@ -373,10 +374,12 @@ function buildDiagram(model, geo) {
373
374
  const pushEdge = (id) => {
374
375
  const wps = geo.waypoints[id];
375
376
  if (!wps) return;
376
- const pts = wps.map(([x, y]) =>
377
- el('omgdi:waypoint', { '@_x': String(x), '@_y': String(y) }, [])
378
- );
379
- edgeEls.push(el('bpmndi:BPMNEdge', { '@_id': `${id}_di`, '@_bpmnElement': id }, pts));
377
+ const children = [];
378
+ const lp = geo.labelPos?.[id];
379
+ for (const [x, y] of wps)
380
+ children.push(el('omgdi:waypoint', { '@_x': String(x), '@_y': String(y) }, []));
381
+ if (lp) children.push(el('bpmndi:BPMNLabel', {}, [el('omgdc:Bounds', boundsAttrs(lp), [])]));
382
+ edgeEls.push(el('bpmndi:BPMNEdge', { '@_id': `${id}_di`, '@_bpmnElement': id }, children));
380
383
  };
381
384
  for (const e of model.edges) pushEdge(e.id);
382
385
  for (const a of model.associations || []) pushEdge(a.id);
@@ -91,9 +91,11 @@ function toSvg(structure) {
91
91
  `<polyline points="${pts}" fill="none" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>`
92
92
  );
93
93
  if (e.name) {
94
- const m = e.waypoints[Math.floor(e.waypoints.length / 2)];
94
+ const lp = e.labelPos;
95
+ const lx = lp ? lp.x + lp.width / 2 : e.waypoints[Math.floor(e.waypoints.length / 2)][0];
96
+ const ly = lp ? lp.y + lp.height / 2 : e.waypoints[Math.floor(e.waypoints.length / 2)][1] - 4;
95
97
  out.push(
96
- `<text x="${m[0] + ox}" y="${m[1] + oy - 4}" text-anchor="middle" fill="#666" font-size="9">${esc(e.name)}</text>`
98
+ `<text x="${lx + ox}" y="${ly + oy}" text-anchor="middle" fill="#666" font-size="9">${esc(e.name)}</text>`
97
99
  );
98
100
  }
99
101
  }
@@ -169,6 +169,7 @@ function flattenNodes(structNodes, parentId, model, read, geo) {
169
169
  if (se.documentation !== undefined) e.documentation = se.documentation;
170
170
  if (parentId) e.parent = parentId;
171
171
  if (se.waypoints) geo.waypoints[se.id] = se.waypoints;
172
+ if (se.labelPos) geo.labelPos[se.id] = se.labelPos;
172
173
  model.edges.push(e);
173
174
  }
174
175
  if (sn.nodes) flattenNodes(sn.nodes, sn.id, model, read, geo);
@@ -264,7 +265,7 @@ function recompose(read) {
264
265
  annotations: structure.annotations || [],
265
266
  associations: structure.associations || [],
266
267
  };
267
- const geo = { bounds: {}, waypoints: {}, expanded: {} };
268
+ const geo = { bounds: {}, waypoints: {}, expanded: {}, labelPos: {} };
268
269
  flattenNodes(structure.nodes, null, model, read, geo);
269
270
  // annotation/association geometry (top-level lists carry it inline)
270
271
  for (const a of model.annotations) if (a.layout) geo.bounds[a.id] = a.layout;
@@ -342,6 +343,15 @@ function localChecksum(read) {
342
343
  return computeChecksum(stableStringify(recompose(read)));
343
344
  }
344
345
 
346
+ // Semantic checksum of a remote payload. Decompose → recompose normalises field
347
+ // order and non-semantic server-side differences, same pipeline as localChecksum
348
+ // so the two are directly comparable.
349
+ function remoteChecksumPb(payload) {
350
+ const { files } = decompose(payload);
351
+ const read = (p) => files[p] ?? '';
352
+ return computeChecksum(stableStringify(recompose(read)));
353
+ }
354
+
345
355
  // Enumerate the wizard's pages from the pages/*.yml files (the authoritative
346
356
  // local set). Each page links to its userTask via `page._id.stepkey`; the
347
357
  // manifest supplies the upstream page guid (absent => a locally-added page that
@@ -379,6 +389,7 @@ export {
379
389
  verifyRoundTrip,
380
390
  stableStringify,
381
391
  localChecksum,
392
+ remoteChecksumPb,
382
393
  enumeratePages,
383
394
  stringifyStructure,
384
395
  pad,