@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.
- package/README.md +50 -11
- package/agrippa-pb.md +375 -0
- package/package.json +7 -3
- package/src/agrippa/commands/clone.js +29 -13
- package/src/agrippa/commands/clonePb.js +107 -0
- package/src/agrippa/commands/diff.js +109 -20
- package/src/agrippa/commands/init.js +66 -12
- package/src/agrippa/commands/initPhase.js +16 -17
- package/src/agrippa/commands/pb.js +291 -0
- package/src/agrippa/commands/pull.js +122 -16
- package/src/agrippa/commands/pullPb.js +54 -0
- package/src/agrippa/commands/push.js +112 -48
- package/src/agrippa/commands/pushPb.js +87 -0
- package/src/agrippa/commands/repair.js +3 -1
- package/src/agrippa/index.js +144 -14
- package/src/agrippa/lib/api.js +17 -3
- package/src/agrippa/lib/checksum.js +3 -1
- package/src/agrippa/lib/config.js +2 -2
- package/src/agrippa/lib/pbApi.js +71 -0
- package/src/agrippa/lib/pbEdit.js +543 -0
- package/src/agrippa/lib/pbLayout.js +304 -0
- package/src/agrippa/lib/pbModel.js +701 -0
- package/src/agrippa/lib/pbPreview.js +153 -0
- package/src/agrippa/lib/pbProject.js +401 -0
- package/src/agrippa/lib/pbWorkspace.js +30 -0
- package/src/agrippa/lib/workspace.js +23 -3
- package/src/commands/autopr.js +5 -2
- package/src/commands/changelog.js +4 -1
- package/src/commands/export.js +3 -3
- package/src/commands/exportEmailTemplates.js +25 -15
- package/src/commands/exportImperex.js +4 -4
- package/src/commands/exportLrp.js +10 -7
- package/src/commands/exportPb.js +4 -5
- package/src/commands/exportWorkflow.js +27 -14
- package/src/commands/init.js +7 -0
- package/src/commands/routine.js +7 -5
- package/src/index.js +24 -7
- package/src/lib/premigrate.js +3 -3
- 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
|
+
};
|