@waron97/prbot 3.1.4 → 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.
Files changed (39) hide show
  1. package/README.md +50 -11
  2. package/agrippa-pb.md +375 -0
  3. package/package.json +7 -3
  4. package/src/agrippa/commands/clone.js +29 -13
  5. package/src/agrippa/commands/clonePb.js +107 -0
  6. package/src/agrippa/commands/diff.js +109 -20
  7. package/src/agrippa/commands/init.js +66 -12
  8. package/src/agrippa/commands/initPhase.js +16 -17
  9. package/src/agrippa/commands/pb.js +291 -0
  10. package/src/agrippa/commands/pull.js +122 -16
  11. package/src/agrippa/commands/pullPb.js +54 -0
  12. package/src/agrippa/commands/push.js +112 -48
  13. package/src/agrippa/commands/pushPb.js +87 -0
  14. package/src/agrippa/commands/repair.js +3 -1
  15. package/src/agrippa/index.js +144 -14
  16. package/src/agrippa/lib/api.js +17 -3
  17. package/src/agrippa/lib/checksum.js +3 -1
  18. package/src/agrippa/lib/config.js +2 -2
  19. package/src/agrippa/lib/pbApi.js +71 -0
  20. package/src/agrippa/lib/pbEdit.js +543 -0
  21. package/src/agrippa/lib/pbLayout.js +304 -0
  22. package/src/agrippa/lib/pbModel.js +701 -0
  23. package/src/agrippa/lib/pbPreview.js +153 -0
  24. package/src/agrippa/lib/pbProject.js +401 -0
  25. package/src/agrippa/lib/pbWorkspace.js +30 -0
  26. package/src/agrippa/lib/workspace.js +23 -3
  27. package/src/commands/autopr.js +5 -2
  28. package/src/commands/changelog.js +4 -1
  29. package/src/commands/export.js +3 -3
  30. package/src/commands/exportEmailTemplates.js +25 -15
  31. package/src/commands/exportImperex.js +4 -4
  32. package/src/commands/exportLrp.js +10 -7
  33. package/src/commands/exportPb.js +4 -5
  34. package/src/commands/exportWorkflow.js +27 -14
  35. package/src/commands/init.js +7 -0
  36. package/src/commands/routine.js +7 -5
  37. package/src/index.js +24 -7
  38. package/src/lib/premigrate.js +3 -3
  39. package/src/lib/updateCheck.js +5 -2
@@ -0,0 +1,71 @@
1
+ // Process-builder API client. Base URL = PB_URL, e.g.
2
+ // https://sorgenia-test-02.symple.cloud/api/processbuilder/v1
3
+ // Auth uses the same Keycloak bearer as import-export (getToken()).
4
+
5
+ import fetch from 'node-fetch';
6
+
7
+ function pbBase() {
8
+ const base = process.env.PB_URL;
9
+ if (!base) {
10
+ throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
11
+ }
12
+ return base.replace(/\/+$/, '');
13
+ }
14
+
15
+ async function pbRequest(method, path, token, body) {
16
+ const hasBody = body !== undefined;
17
+ const res = await fetch(`${pbBase()}${path}`, {
18
+ method,
19
+ headers: {
20
+ Authorization: `Bearer ${token}`,
21
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
22
+ },
23
+ body: hasBody ? JSON.stringify(body) : undefined,
24
+ });
25
+ if (!res.ok) {
26
+ throw new Error(`PB API ${method} ${path} failed with ${res.status}: ${await res.text()}`);
27
+ }
28
+ const text = await res.text();
29
+ return text ? JSON.parse(text) : null;
30
+ }
31
+
32
+ // List processes. The endpoint returns full objects; we keep only the
33
+ // identity fields needed for selection.
34
+ async function listProcesses(token) {
35
+ const data = await pbRequest('GET', '/builder/process', token);
36
+ const arr = Array.isArray(data) ? data : (data.data ?? data.results ?? []);
37
+ return arr.map((p) => ({
38
+ guid: p.guid,
39
+ document_id: p.document_id,
40
+ process_name: p.process_name,
41
+ status: p.status,
42
+ version: p.version,
43
+ }));
44
+ }
45
+
46
+ // Fetch the full payload for one process by guid.
47
+ function getProcess(token, guid) {
48
+ return pbRequest('GET', `/builder/process/${guid}`, token);
49
+ }
50
+
51
+ // Save the whole wizard (blocks/structure/scalars). Body = full payload. Wizard → draft.
52
+ function updateProcess(token, guid, payload) {
53
+ return pbRequest('PATCH', `/builder/process/${guid}`, token, payload);
54
+ }
55
+
56
+ // Create a new user-task page. Body = { name, page }. Returns the created page (with guid).
57
+ function createPage(token, guid, body) {
58
+ return pbRequest('POST', `/builder/process/${guid}/page`, token, body);
59
+ }
60
+
61
+ // Update an existing user-task page. Body = { name, page }.
62
+ function updatePage(token, guid, pageGuid, body) {
63
+ return pbRequest('PATCH', `/builder/process/${guid}/page/${pageGuid}`, token, body);
64
+ }
65
+
66
+ // Publish the wizard so live consumers see the latest saved state.
67
+ function publishProcess(token, guid) {
68
+ return pbRequest('POST', `/builder/process/publish/${guid}`, token, null);
69
+ }
70
+
71
+ export { listProcesses, getProcess, updateProcess, createPage, updatePage, publishProcess };
@@ -0,0 +1,543 @@
1
+ // Structural graph helpers for a decomposed process-builder project.
2
+ //
3
+ // These operate on the parsed structure.yaml object (the nested graph: nodes
4
+ // hold their own outgoing `edges` and, for containers, nested `nodes`). They
5
+ // cover the ops where hand-editing the multi-thousand-line YAML traps an agent:
6
+ // BPMN id generation, dangling-edge cleanup on remove, subProcess nesting, and
7
+ // multi-file scaffolding (script/page/manifest). Content edits (rename, change a
8
+ // condition, edit a script body) stay raw-YAML — a helper there adds nothing.
9
+ //
10
+ // Functions mutate `structure`/`manifest` in place and return any file
11
+ // side-effects (`writes`/`deletes`) plus a `result` for the CLI to report.
12
+ // Geometry is intentionally stubbed (correct size, placeholder position) — the
13
+ // agent runs `pb format` to finalize layout. No IO happens here.
14
+
15
+ import { stringify as yamlStringify } from 'yaml';
16
+ import { pad, toSlug } from './pbProject.js';
17
+
18
+ // Fixed element sizes (measured across all fixtures). Containers are sized by
19
+ // the layout engine; we seed a small default so a stubbed clone stays valid.
20
+ const SIZE = {
21
+ startEvent: [36, 36],
22
+ endEvent: [36, 36],
23
+ boundaryEvent: [36, 36],
24
+ exclusiveGateway: [50, 50],
25
+ scriptTask: [84, 84],
26
+ serviceTask: [84, 84],
27
+ userTask: [84, 84],
28
+ subProcess: [350, 200],
29
+ transaction: [350, 200],
30
+ };
31
+
32
+ // BPMN id prefixes by node type (mirrors the upstream naming convention).
33
+ const PREFIX = {
34
+ startEvent: 'StartEvent',
35
+ endEvent: 'EndEvent',
36
+ boundaryEvent: 'BoundaryEvent',
37
+ exclusiveGateway: 'ExclusiveGateway',
38
+ scriptTask: 'ScriptTask',
39
+ serviceTask: 'ServiceTask',
40
+ userTask: 'UserTask',
41
+ subProcess: 'SubProcess',
42
+ transaction: 'Transaction',
43
+ };
44
+
45
+ const CONTAINER = new Set(['subProcess', 'transaction']);
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
+
61
+ // Depth-first visit of every node in the nested graph, with its parent node.
62
+ function eachNode(nodes, parent, fn) {
63
+ for (const n of nodes || []) {
64
+ fn(n, parent);
65
+ if (n.nodes) eachNode(n.nodes, n, fn);
66
+ }
67
+ }
68
+
69
+ // All ids in use (nodes, edges, annotations, associations) — for collision-free gen.
70
+ function collectIds(structure) {
71
+ const ids = new Set();
72
+ eachNode(structure.nodes, null, (n) => {
73
+ ids.add(n.id);
74
+ for (const e of n.edges || []) ids.add(e.id);
75
+ });
76
+ for (const a of structure.annotations || []) ids.add(a.id);
77
+ for (const a of structure.associations || []) ids.add(a.id);
78
+ return ids;
79
+ }
80
+
81
+ function rand() {
82
+ return Math.random().toString(36).slice(2, 9).padEnd(7, '0');
83
+ }
84
+
85
+ function genId(structure, prefix) {
86
+ const ids = collectIds(structure);
87
+ let id;
88
+ do {
89
+ id = `${prefix}_${rand()}`;
90
+ } while (ids.has(id));
91
+ return id;
92
+ }
93
+
94
+ // Locate a node anywhere in the tree → { node, list (its containing array), parent }.
95
+ function findNode(structure, id) {
96
+ let found = null;
97
+ const search = (nodes, parent) => {
98
+ for (const n of nodes || []) {
99
+ if (n.id === id) {
100
+ found = { node: n, list: nodes, parent };
101
+ return true;
102
+ }
103
+ if (n.nodes && search(n.nodes, n)) return true;
104
+ }
105
+ return false;
106
+ };
107
+ search(structure.nodes, null);
108
+ return found;
109
+ }
110
+
111
+ // Center point of a node from its (possibly stub) layout — for placeholder waypoints.
112
+ function centerOf(n) {
113
+ const l = n.layout || { x: 0, y: 0, width: 0, height: 0 };
114
+ return [Math.round(l.x + (l.width || 0) / 2), Math.round(l.y + (l.height || 0) / 2)];
115
+ }
116
+
117
+ // Next script sequence number (max existing ×10 step, default 10).
118
+ function nextScriptSeq(existingScriptFiles) {
119
+ let max = 0;
120
+ for (const f of existingScriptFiles || []) {
121
+ const m = /(?:^|\/)(\d{4})_/.exec(f);
122
+ if (m) max = Math.max(max, parseInt(m[1], 10));
123
+ }
124
+ return max + 10;
125
+ }
126
+
127
+ // ---------- add ----------
128
+
129
+ // Add a node. For scriptTask, scaffolds an empty script file and refs it. For
130
+ // userTask, scaffolds a minimal page file + a manifest page entry (guid=null →
131
+ // push will create it upstream). Container nodes start empty + expanded.
132
+ function addNode(structure, manifest, opts, ctx = {}) {
133
+ const { type, name, parentId } = opts;
134
+ if (!PREFIX[type]) throw new Error(`Unknown node type: ${type}`);
135
+
136
+ const id = genId(structure, PREFIX[type]);
137
+ const [w, h] = SIZE[type];
138
+ const node = { id, type };
139
+ if (name !== undefined) node.name = name;
140
+ node.layout = { x: 0, y: 0, width: w, height: h };
141
+
142
+ const writes = {};
143
+
144
+ if (type === 'scriptTask') {
145
+ const seq = nextScriptSeq(ctx.existingScripts);
146
+ const file = `scripts/${pad(seq)}_${toSlug(name || id)}.js`;
147
+ writes[file] = '';
148
+ node.script = file;
149
+ } else if (type === 'userTask') {
150
+ const formKey = toSlug(name || id);
151
+ node.formKey = formKey;
152
+ const file = `pages/${formKey}.yml`;
153
+ const pageObj = {
154
+ _id: { stepkey: id, processkey: ctx.documentId ?? manifest?.document_id ?? null },
155
+ columns: 1,
156
+ entities: [],
157
+ language: '',
158
+ page_name: '',
159
+ page_builder: [],
160
+ };
161
+ writes[file] = yamlStringify(pageObj, { lineWidth: 0 });
162
+ manifest.pages = manifest.pages || {};
163
+ manifest.pages[id] = {
164
+ file,
165
+ wrapper: {
166
+ name: id,
167
+ guid: null,
168
+ process_guid: manifest.guid ?? null,
169
+ system_record: false,
170
+ owner_group: null,
171
+ },
172
+ };
173
+ } else if (CONTAINER.has(type)) {
174
+ node.expanded = 'true';
175
+ node.nodes = [];
176
+ }
177
+
178
+ if (DEFAULT_ATTRS[type]) node.attrs = { ...DEFAULT_ATTRS[type] };
179
+
180
+ if (parentId) {
181
+ const f = findNode(structure, parentId);
182
+ if (!f) throw new Error(`parent not found: ${parentId}`);
183
+ if (!CONTAINER.has(f.node.type))
184
+ throw new Error(`parent ${parentId} is not a container (subProcess/transaction)`);
185
+ f.node.nodes = f.node.nodes || [];
186
+ f.node.nodes.push(node);
187
+ } else {
188
+ structure.nodes.push(node);
189
+ }
190
+
191
+ return { writes, deletes: [], result: { id, type, file: writes && Object.keys(writes)[0] } };
192
+ }
193
+
194
+ // Add a node spliced between two already-connected nodes: there must be exactly
195
+ // one sequenceFlow `from` → `to` already (none, or more than one, is an error —
196
+ // ambiguous or nothing to splice). That edge (id, name, condition, default-flag
197
+ // reference — all keyed by edge id) is kept and retargeted onto the new node; a
198
+ // second plain edge runs new-node → `to`. `from`/`to` must share the same
199
+ // container — boundary-crossing flows aren't auto-spliced.
200
+ function addNodeBetween(structure, manifest, opts, ctx = {}) {
201
+ const { from, to, type, name } = opts;
202
+ if (!PREFIX[type]) throw new Error(`Unknown node type: ${type}`);
203
+
204
+ const sf = findNode(structure, from);
205
+ if (!sf) throw new Error(`source node not found: ${from}`);
206
+ const st = findNode(structure, to);
207
+ if (!st) throw new Error(`target node not found: ${to}`);
208
+
209
+ const sfParentId = sf.parent ? sf.parent.id : null;
210
+ const stParentId = st.parent ? st.parent.id : null;
211
+ if (sfParentId !== stParentId) {
212
+ throw new Error(
213
+ `${from} and ${to} are in different containers (boundary-crossing flow) — ` +
214
+ 'insert not supported automatically; use `pb add` + `pb connect`/`pb disconnect` manually.'
215
+ );
216
+ }
217
+
218
+ const candidates = (sf.node.edges || []).filter((e) => e.target === to);
219
+ if (candidates.length !== 1) {
220
+ throw new Error(
221
+ candidates.length === 0
222
+ ? `no edge ${from} → ${to}; nothing to insert between.`
223
+ : `${candidates.length} edges ${from} → ${to} (${candidates.map((e) => e.id).join(', ')}); ` +
224
+ 'must be exactly one to insert between.'
225
+ );
226
+ }
227
+ const edge = candidates[0];
228
+
229
+ const { writes, result } = addNode(
230
+ structure,
231
+ manifest,
232
+ { type, name, parentId: sfParentId || undefined },
233
+ ctx
234
+ );
235
+ const newNode = findNode(structure, result.id).node;
236
+
237
+ edge.target = result.id;
238
+ edge.waypoints = [centerOf(sf.node), centerOf(newNode)];
239
+
240
+ const id2 = genId(structure, 'SequenceFlow');
241
+ newNode.edges = newNode.edges || [];
242
+ newNode.edges.push({ id: id2, target: to, waypoints: [centerOf(newNode), centerOf(st.node)] });
243
+
244
+ const warnings =
245
+ sf.node.type === 'exclusiveGateway'
246
+ ? lintGateways(structure).filter((w) => w.startsWith(from))
247
+ : [];
248
+
249
+ return {
250
+ writes,
251
+ deletes: [],
252
+ result: {
253
+ id: result.id,
254
+ type,
255
+ file: result.file,
256
+ edgeId: edge.id,
257
+ newEdgeId: id2,
258
+ warnings,
259
+ },
260
+ };
261
+ }
262
+
263
+ // ---------- remove ----------
264
+
265
+ // Remove a node (and, recursively, its container children). Drops the node's own
266
+ // outgoing edges (they nest under it) AND every edge elsewhere targeting it or a
267
+ // descendant, plus any associations referencing them. Deletes the script/page
268
+ // files and manifest page entries for the removed userTask/scriptTask nodes.
269
+ function removeNode(structure, manifest, { id }) {
270
+ const f = findNode(structure, id);
271
+ if (!f) throw new Error(`node not found: ${id}`);
272
+
273
+ const victims = [];
274
+ const collect = (n) => {
275
+ victims.push(n);
276
+ for (const c of n.nodes || []) collect(c);
277
+ };
278
+ collect(f.node);
279
+ const victimIds = new Set(victims.map((n) => n.id));
280
+
281
+ const deletes = [];
282
+ for (const n of victims) {
283
+ if (n.type === 'scriptTask' && n.script) deletes.push(n.script);
284
+ if (n.type === 'userTask') {
285
+ const info = manifest.pages?.[n.id];
286
+ if (info?.file) deletes.push(info.file);
287
+ if (manifest.pages) delete manifest.pages[n.id];
288
+ }
289
+ }
290
+
291
+ // unlink the node from its containing list
292
+ f.list.splice(f.list.indexOf(f.node), 1);
293
+
294
+ // drop dangling edges (inbound from outside the removed subtree)
295
+ let removedEdges = 0;
296
+ eachNode(structure.nodes, null, (n) => {
297
+ if (!n.edges) return;
298
+ const before = n.edges.length;
299
+ n.edges = n.edges.filter((e) => !victimIds.has(e.target));
300
+ removedEdges += before - n.edges.length;
301
+ });
302
+ if (structure.associations) {
303
+ structure.associations = structure.associations.filter(
304
+ (a) => !victimIds.has(a.sourceRef) && !victimIds.has(a.targetRef)
305
+ );
306
+ }
307
+
308
+ return { writes: {}, deletes, result: { removed: [...victimIds], removedEdges } };
309
+ }
310
+
311
+ // ---------- connect / disconnect ----------
312
+
313
+ // Add a sequenceFlow from `from` to `to`. Stub straight-line waypoints (center→
314
+ // center) keep the project recompose-valid until `pb format` reroutes.
315
+ //
316
+ // exclusiveGateway rule (enforced by Activiti): when a gateway has >1 outgoing
317
+ // flow, exactly one must be the `default` and every other must carry a condition
318
+ // expression. `--default` sets the source gateway's default to the new edge;
319
+ // otherwise a non-default flow should be given a `--condition`. Violations are
320
+ // returned as `result.warnings` (non-blocking) so the caller can surface them.
321
+ function connect(structure, { from, to, name, condition, conditionType, makeDefault }) {
322
+ const sf = findNode(structure, from);
323
+ if (!sf) throw new Error(`source node not found: ${from}`);
324
+ const st = findNode(structure, to);
325
+ if (!st) throw new Error(`target node not found: ${to}`);
326
+
327
+ const id = genId(structure, 'SequenceFlow');
328
+ const edge = { id, target: to };
329
+ if (name !== undefined) edge.name = name;
330
+ if (condition !== undefined) {
331
+ edge.condition = condition;
332
+ edge.conditionType = conditionType || 'tFormalExpression';
333
+ }
334
+ edge.waypoints = [centerOf(sf.node), centerOf(st.node)];
335
+
336
+ sf.node.edges = sf.node.edges || [];
337
+ sf.node.edges.push(edge);
338
+
339
+ const warnings = [];
340
+ if (makeDefault) {
341
+ if (sf.node.type !== 'exclusiveGateway') {
342
+ warnings.push(
343
+ `--default only applies to exclusiveGateway; ${from} is ${sf.node.type}.`
344
+ );
345
+ } else {
346
+ const prev = sf.node.attrs?.default;
347
+ sf.node.attrs = sf.node.attrs || {};
348
+ sf.node.attrs.default = id;
349
+ if (prev && prev !== id)
350
+ warnings.push(`replaced previous default flow ${prev} on ${from}.`);
351
+ }
352
+ }
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)));
356
+
357
+ return { writes: {}, deletes: [], result: { id, from, to, warnings } };
358
+ }
359
+
360
+ // Mark an existing outgoing flow as the source gateway's default — by edge id,
361
+ // or by --from/--to pair. The flow must already exist (use `pb connect` to add a
362
+ // new one); this only flips the default flag, so the agent no longer has to
363
+ // delete and re-add a connection just to change which one is default. The owner
364
+ // must be an exclusiveGateway. Returns the previous default (if any) and a fresh
365
+ // gateway lint as non-blocking warnings.
366
+ function setDefault(structure, { id, from, to }) {
367
+ let owner = null;
368
+ let edge = null;
369
+ eachNode(structure.nodes, null, (n) => {
370
+ if (owner || !n.edges) return;
371
+ const e = id
372
+ ? n.edges.find((x) => x.id === id)
373
+ : n.id === from && n.edges.find((x) => x.target === to);
374
+ if (e) {
375
+ owner = n;
376
+ edge = e;
377
+ }
378
+ });
379
+ if (!edge) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
380
+ if (owner.type !== 'exclusiveGateway')
381
+ throw new Error(
382
+ `--default only applies to exclusiveGateway; ${owner.id} is ${owner.type}.`
383
+ );
384
+
385
+ const prev = owner.attrs?.default;
386
+ owner.attrs = owner.attrs || {};
387
+ owner.attrs.default = edge.id;
388
+
389
+ const warnings = lintGateways(structure).filter((w) => w.startsWith(owner.id));
390
+ return {
391
+ writes: {},
392
+ deletes: [],
393
+ result: { id: edge.id, from: owner.id, to: edge.target, prev, warnings },
394
+ };
395
+ }
396
+
397
+ // Flag exclusiveGateways that violate the default/condition rule: a gateway with
398
+ // >1 outgoing flow needs exactly one default, and every non-default flow needs a
399
+ // condition. Returns human-readable issue strings (empty = all good).
400
+ function lintGateways(structure) {
401
+ const issues = [];
402
+ eachNode(structure.nodes, null, (n) => {
403
+ if (n.type !== 'exclusiveGateway') return;
404
+ const outs = n.edges || [];
405
+ if (outs.length < 2) return;
406
+ const def = n.attrs?.default;
407
+ const hasDefault = def && outs.some((e) => e.id === def);
408
+ if (!hasDefault) {
409
+ issues.push(
410
+ `${n.id} (${n.name || 'gateway'}): ${outs.length} outgoing flows but no default — mark one with --default.`
411
+ );
412
+ }
413
+ for (const e of outs) {
414
+ if (e.id === def) continue;
415
+ if (e.condition === undefined) {
416
+ issues.push(
417
+ `${n.id} → ${e.target} (${e.id}): non-default flow without a condition expression.`
418
+ );
419
+ }
420
+ }
421
+ });
422
+ return issues;
423
+ }
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
+
483
+ // Remove an edge by id, or by --from/--to pair.
484
+ function disconnect(structure, { id, from, to }) {
485
+ let removed = 0;
486
+ let removedId = null;
487
+ eachNode(structure.nodes, null, (n) => {
488
+ if (!n.edges) return;
489
+ const before = n.edges.length;
490
+ n.edges = n.edges.filter((e) => {
491
+ const match = id ? e.id === id : n.id === from && e.target === to;
492
+ if (match) removedId = e.id;
493
+ return !match;
494
+ });
495
+ removed += before - n.edges.length;
496
+ });
497
+ if (!removed) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
498
+ return { writes: {}, deletes: [], result: { removed, id: removedId } };
499
+ }
500
+
501
+ // ---------- list ----------
502
+
503
+ // Flat rows for `pb ls` so an agent can discover ids/targets without reading YAML.
504
+ function listGraph(structure) {
505
+ const rows = [];
506
+ eachNode(structure.nodes, null, (n, parent) => {
507
+ const def = n.attrs?.default;
508
+ rows.push({
509
+ id: n.id,
510
+ type: n.type,
511
+ name: n.name ?? '',
512
+ parent: parent ? parent.id : null,
513
+ edges: (n.edges || []).map((e) => ({
514
+ id: e.id,
515
+ target: e.target,
516
+ name: e.name,
517
+ condition: e.condition,
518
+ isDefault: def === e.id,
519
+ })),
520
+ });
521
+ });
522
+ return rows;
523
+ }
524
+
525
+ export {
526
+ SIZE,
527
+ PREFIX,
528
+ CONTAINER,
529
+ eachNode,
530
+ findNode,
531
+ genId,
532
+ addNode,
533
+ addNodeBetween,
534
+ removeNode,
535
+ connect,
536
+ disconnect,
537
+ setDefault,
538
+ listGraph,
539
+ lintGateways,
540
+ lintEdgeNames,
541
+ lintIncomingEdges,
542
+ lintAll,
543
+ };