@waron97/prbot 3.1.3 → 3.2.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.
Files changed (39) hide show
  1. package/README.md +50 -11
  2. package/agrippa-pb.md +350 -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 +94 -18
  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 +279 -0
  10. package/src/agrippa/commands/pull.js +119 -13
  11. package/src/agrippa/commands/pullPb.js +54 -0
  12. package/src/agrippa/commands/push.js +112 -47
  13. package/src/agrippa/commands/pushPb.js +87 -0
  14. package/src/agrippa/commands/repair.js +3 -1
  15. package/src/agrippa/index.js +138 -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 +467 -0
  21. package/src/agrippa/lib/pbLayout.js +283 -0
  22. package/src/agrippa/lib/pbModel.js +698 -0
  23. package/src/agrippa/lib/pbPreview.js +151 -0
  24. package/src/agrippa/lib/pbProject.js +390 -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 +7 -2
  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,151 @@
1
+ // Quick-and-dirty SVG render of a decomposed wizard's diagram, for eyeballing
2
+ // the output of `pb format` during development. Not byte-faithful to the BPMN
3
+ // renderer — it just draws each node by type (event=circle, gateway=diamond,
4
+ // task=rounded rect, container=rect) and each edge as a waypoint polyline, from
5
+ // the geometry in structure.yaml.
6
+
7
+ import { CONTAINER, eachNode } from './pbEdit.js';
8
+
9
+ const EVENT = new Set(['startEvent', 'endEvent', 'boundaryEvent']);
10
+
11
+ function esc(s) {
12
+ return String(s ?? '')
13
+ .replace(/&/g, '&')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;');
16
+ }
17
+
18
+ function toSvg(structure) {
19
+ const leaves = [];
20
+ const containers = [];
21
+ const edges = [];
22
+ eachNode(structure.nodes, null, (n) => {
23
+ if (!n.layout) return;
24
+ if (CONTAINER.has(n.type)) containers.push(n);
25
+ else leaves.push(n);
26
+ for (const e of n.edges || []) if (e.waypoints?.length) edges.push(e);
27
+ });
28
+ const anns = (structure.annotations || []).filter((a) => a.layout);
29
+ const assocs = (structure.associations || []).filter((a) => a.waypoints?.length);
30
+
31
+ let minX = Infinity;
32
+ let minY = Infinity;
33
+ let maxX = -Infinity;
34
+ let maxY = -Infinity;
35
+ const ext = (x, y) => {
36
+ minX = Math.min(minX, x);
37
+ minY = Math.min(minY, y);
38
+ maxX = Math.max(maxX, x);
39
+ maxY = Math.max(maxY, y);
40
+ };
41
+ for (const n of [...leaves, ...containers, ...anns]) {
42
+ const l = n.layout;
43
+ ext(l.x, l.y);
44
+ ext(l.x + l.width, l.y + l.height);
45
+ }
46
+ for (const e of [...edges, ...assocs]) for (const [x, y] of e.waypoints) ext(x, y);
47
+ if (!Number.isFinite(minX)) {
48
+ minX = 0;
49
+ minY = 0;
50
+ maxX = 100;
51
+ maxY = 100;
52
+ }
53
+
54
+ const pad = 40;
55
+ const W = Math.round(maxX - minX + pad * 2);
56
+ const H = Math.round(maxY - minY + pad * 2);
57
+ const ox = pad - minX;
58
+ const oy = pad - minY;
59
+
60
+ const out = [];
61
+ out.push(
62
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="sans-serif" font-size="11">`
63
+ );
64
+ out.push(
65
+ '<defs><marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">' +
66
+ '<path d="M0,0 L8,3 L0,6 z" fill="#444"/></marker></defs>'
67
+ );
68
+ out.push('<rect width="100%" height="100%" fill="#fff"/>');
69
+
70
+ const label = (cx, cy, text) =>
71
+ text
72
+ ? `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle">${esc(text)}</text>`
73
+ : '';
74
+
75
+ // containers behind everything
76
+ for (const n of containers) {
77
+ const l = n.layout;
78
+ const x = l.x + ox;
79
+ const y = l.y + oy;
80
+ out.push(
81
+ `<rect x="${x}" y="${y}" width="${l.width}" height="${l.height}" rx="8" fill="#f5f7fa" stroke="#9aa7b4"/>`
82
+ );
83
+ out.push(
84
+ `<text x="${x + 8}" y="${y + 16}" font-weight="bold">${esc(n.name || n.type)}</text>`
85
+ );
86
+ }
87
+ // edges
88
+ for (const e of edges) {
89
+ const pts = e.waypoints.map(([x, y]) => `${x + ox},${y + oy}`).join(' ');
90
+ out.push(
91
+ `<polyline points="${pts}" fill="none" stroke="#444" stroke-width="1.5" marker-end="url(#arrow)"/>`
92
+ );
93
+ if (e.name) {
94
+ const m = e.waypoints[Math.floor(e.waypoints.length / 2)];
95
+ 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>`
97
+ );
98
+ }
99
+ }
100
+ for (const a of assocs) {
101
+ const pts = a.waypoints.map(([x, y]) => `${x + ox},${y + oy}`).join(' ');
102
+ out.push(
103
+ `<polyline points="${pts}" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="3,3"/>`
104
+ );
105
+ }
106
+ // annotations
107
+ for (const a of anns) {
108
+ const l = a.layout;
109
+ const x = l.x + ox;
110
+ const y = l.y + oy;
111
+ out.push(
112
+ `<rect x="${x}" y="${y}" width="${l.width}" height="${l.height}" fill="#fffbe6" stroke="#d4b106"/>`
113
+ );
114
+ out.push(
115
+ `<text x="${x + 4}" y="${y + 14}" font-size="9">${esc((a.text || '').slice(0, 40))}</text>`
116
+ );
117
+ }
118
+ // leaf shapes on top
119
+ for (const n of leaves) {
120
+ const l = n.layout;
121
+ const x = l.x + ox;
122
+ const y = l.y + oy;
123
+ const cx = x + l.width / 2;
124
+ const cy = y + l.height / 2;
125
+ if (EVENT.has(n.type)) {
126
+ const sw = n.type === 'endEvent' ? 3 : 1.5;
127
+ out.push(
128
+ `<circle cx="${cx}" cy="${cy}" r="${l.width / 2}" fill="#fff" stroke="#333" stroke-width="${sw}"/>`
129
+ );
130
+ out.push(label(cx, y + l.height + 10, n.name));
131
+ } else if (n.type === 'exclusiveGateway') {
132
+ const p = `${cx},${y} ${x + l.width},${cy} ${cx},${y + l.height} ${x},${cy}`;
133
+ out.push(`<polygon points="${p}" fill="#fff" stroke="#333"/>`);
134
+ out.push(
135
+ `<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="middle" font-size="13">&#215;</text>`
136
+ );
137
+ out.push(label(cx, y + l.height + 10, n.name));
138
+ } else {
139
+ const fill =
140
+ n.type === 'userTask' ? '#e6f4ff' : n.type === 'serviceTask' ? '#f0ffe6' : '#fff';
141
+ out.push(
142
+ `<rect x="${x}" y="${y}" width="${l.width}" height="${l.height}" rx="8" fill="${fill}" stroke="#333"/>`
143
+ );
144
+ out.push(label(cx, cy, n.name));
145
+ }
146
+ }
147
+ out.push('</svg>');
148
+ return out.join('\n');
149
+ }
150
+
151
+ export { toSvg };
@@ -0,0 +1,390 @@
1
+ // Decompose a process-builder payload into editable files, and recompose it.
2
+ //
3
+ // Layout (see ai_tasks planning.md):
4
+ // process.yaml editable scalar overlay (identity + flags)
5
+ // structure.yaml the authoritative graph: nodes hold their own outgoing
6
+ // edges and (for subProcess) nested child nodes; each node
7
+ // carries its diagram layout {x,y,width,height}; each edge
8
+ // its waypoints. scriptTask bodies are referenced by file.
9
+ // scripts/NNNN_*.js one scriptTask body each, byte-exact
10
+ // pages/<formKey>.yml one userTask page object each
11
+ // .agrippa-pb.json manifest: all scalars verbatim, ns, full diagram, audit,
12
+ // id<->file maps
13
+ //
14
+ // The manifest carries every top-level scalar and the full diagram verbatim, so
15
+ // nothing is ever lost; process.yaml and the per-node layout/waypoints in
16
+ // structure.yaml *override* the manifest on recompose (so edits flow through).
17
+
18
+ import slugify from 'slugify';
19
+ import { Document, visit, parse as yamlParse, stringify as yamlStringify } from 'yaml';
20
+ import { computeChecksum } from './checksum.js';
21
+ import { buildProcess, compareDiagram, compareProcess, diff, parseProcess } from './pbModel.js';
22
+
23
+ const MANIFEST_FILE = '.agrippa-pb.json';
24
+ const STRUCTURE_FILE = 'structure.yaml';
25
+ const PROCESS_FILE = 'process.yaml';
26
+
27
+ // Scalar fields surfaced in process.yaml (editable). Everything else lives in
28
+ // the manifest. These override manifest scalars on recompose.
29
+ const EDITABLE_SCALARS = [
30
+ 'document_id',
31
+ 'process_name',
32
+ 'version',
33
+ 'icon',
34
+ 'short_description',
35
+ 'status',
36
+ 'active',
37
+ 'is_linear',
38
+ 'execute_save',
39
+ 'progressbar_enabled',
40
+ 'start_date',
41
+ 'end_date',
42
+ 'favorite',
43
+ ];
44
+
45
+ // Container node types whose children nest under them in structure.yaml.
46
+ const CONTAINER_TAGS = new Set(['subProcess', 'transaction']);
47
+
48
+ // Type-specific node fields carried verbatim between the flat model and structure.yaml.
49
+ const NODE_FIELDS = [
50
+ 'attrs',
51
+ 'class',
52
+ 'fields',
53
+ 'formKey',
54
+ 'attachedToRef',
55
+ 'errorEventDefinition',
56
+ 'multiInstance',
57
+ 'documentation',
58
+ ];
59
+
60
+ function toSlug(name) {
61
+ return slugify(String(name ?? ''), { lower: true, strict: true }) || 'node';
62
+ }
63
+ function pad(n) {
64
+ return String(n).padStart(4, '0');
65
+ }
66
+
67
+ // Render structure.yaml with layout/waypoints as compact inline (flow) collections.
68
+ function setFlowDeep(node) {
69
+ if (!node || !node.items) return;
70
+ node.flow = true;
71
+ for (const it of node.items) {
72
+ if (it?.items) setFlowDeep(it); // inner seq (e.g. a waypoint pair)
73
+ if (it?.value?.items) setFlowDeep(it.value);
74
+ }
75
+ }
76
+ function stringifyStructure(obj) {
77
+ const doc = new Document(obj);
78
+ visit(doc, {
79
+ Pair(_, pair) {
80
+ const key = pair.key?.value;
81
+ if (key === 'layout' || key === 'waypoints') setFlowDeep(pair.value);
82
+ },
83
+ });
84
+ return doc.toString({ lineWidth: 0 });
85
+ }
86
+ function pick(obj, keys) {
87
+ const o = {};
88
+ for (const k of keys) if (k in obj) o[k] = obj[k];
89
+ return o;
90
+ }
91
+ function omit(obj, keys) {
92
+ const set = new Set(keys);
93
+ const o = {};
94
+ for (const [k, v] of Object.entries(obj)) if (!set.has(k)) o[k] = v;
95
+ return o;
96
+ }
97
+
98
+ // ---------- geometry maps from a parsed diagram ----------
99
+
100
+ function geometryMaps(diagram) {
101
+ const bounds = {};
102
+ const waypoints = {};
103
+ const expanded = {};
104
+ if (diagram) {
105
+ for (const s of diagram.shapes) {
106
+ if (s.bounds) bounds[s.attrs.bpmnElement] = { ...s.bounds };
107
+ if (s.attrs.isExpanded !== undefined)
108
+ expanded[s.attrs.bpmnElement] = s.attrs.isExpanded;
109
+ }
110
+ for (const e of diagram.edges)
111
+ if (e.waypoints?.length)
112
+ waypoints[e.attrs.bpmnElement] = e.waypoints.map((w) => [w.x, w.y]);
113
+ }
114
+ return { bounds, waypoints, expanded };
115
+ }
116
+
117
+ // ---------- nested structure.yaml (decompose side) ----------
118
+
119
+ function structEdge(e, geo) {
120
+ const o = { id: e.id, target: e.target };
121
+ if (e.name !== undefined) o.name = e.name;
122
+ if (e.condition !== undefined) o.condition = e.condition;
123
+ if (e.conditionType !== undefined) o.conditionType = e.conditionType;
124
+ if (e.documentation !== undefined) o.documentation = e.documentation;
125
+ if (geo.waypoints[e.id]) o.waypoints = geo.waypoints[e.id];
126
+ return o;
127
+ }
128
+
129
+ function structNode(n, model, scriptsMap, geo) {
130
+ const out = { id: n.id, type: n.type };
131
+ if (n.name !== undefined) out.name = n.name;
132
+ for (const k of NODE_FIELDS) if (n[k] !== undefined) out[k] = n[k];
133
+ if (n.type === 'scriptTask') out.script = scriptsMap[n.id];
134
+ if (geo.bounds[n.id]) out.layout = geo.bounds[n.id];
135
+ if (geo.expanded[n.id] !== undefined) out.expanded = geo.expanded[n.id];
136
+ const outEdges = model.edges.filter((e) => e.source === n.id).map((e) => structEdge(e, geo));
137
+ if (outEdges.length) out.edges = outEdges;
138
+ if (CONTAINER_TAGS.has(n.type)) {
139
+ const kids = nestNodes(model, n.id, scriptsMap, geo);
140
+ if (kids.length) out.nodes = kids;
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function nestNodes(model, parentId, scriptsMap, geo) {
146
+ return model.nodes
147
+ .filter((n) => (n.parent ?? null) === parentId)
148
+ .map((n) => structNode(n, model, scriptsMap, geo));
149
+ }
150
+
151
+ // ---------- flatten structure.yaml back to model (recompose side) ----------
152
+
153
+ function flattenNodes(structNodes, parentId, model, read, geo) {
154
+ for (const sn of structNodes || []) {
155
+ const n = { id: sn.id, type: sn.type };
156
+ if (sn.name !== undefined) n.name = sn.name;
157
+ if (parentId) n.parent = parentId;
158
+ for (const k of NODE_FIELDS) if (sn[k] !== undefined) n[k] = sn[k];
159
+ if (sn.type === 'scriptTask') n.script = read(sn.script); // load body from file
160
+ if (sn.layout) geo.bounds[sn.id] = sn.layout;
161
+ if (sn.expanded !== undefined) geo.expanded[sn.id] = sn.expanded;
162
+ model.nodes.push(n);
163
+
164
+ for (const se of sn.edges || []) {
165
+ const e = { id: se.id, source: sn.id, target: se.target };
166
+ if (se.name !== undefined) e.name = se.name;
167
+ if (se.condition !== undefined) e.condition = se.condition;
168
+ if (se.conditionType !== undefined) e.conditionType = se.conditionType;
169
+ if (se.documentation !== undefined) e.documentation = se.documentation;
170
+ if (parentId) e.parent = parentId;
171
+ if (se.waypoints) geo.waypoints[se.id] = se.waypoints;
172
+ model.edges.push(e);
173
+ }
174
+ if (sn.nodes) flattenNodes(sn.nodes, sn.id, model, read, geo);
175
+ }
176
+ }
177
+
178
+ // ---------- decompose: payload -> { files, manifest } ----------
179
+
180
+ function decompose(payload) {
181
+ const { ns, model, diagram } = parseProcess(payload.built_page);
182
+ const geo = geometryMaps(diagram);
183
+ const files = {};
184
+
185
+ // scripts — one file per scriptTask, document order, byte-exact body.
186
+ const scriptsMap = {};
187
+ let seq = 0;
188
+ const usedNames = new Set();
189
+ for (const n of model.nodes) {
190
+ if (n.type !== 'scriptTask') continue;
191
+ seq += 10;
192
+ let base = `${pad(seq)}_${toSlug(n.name || n.id)}`;
193
+ while (usedNames.has(base)) base += '_';
194
+ usedNames.add(base);
195
+ const path = `scripts/${base}.js`;
196
+ files[path] = n.script ?? '';
197
+ scriptsMap[n.id] = path;
198
+ }
199
+
200
+ // pages — one file per userTask page object; wrapper audit kept in manifest.
201
+ const pagesMap = {};
202
+ const usedPageNames = new Set();
203
+ for (const wrapper of payload.pages || []) {
204
+ const stepkey = wrapper.page?._id?.stepkey ?? wrapper.name;
205
+ const ut = model.nodes.find((x) => x.type === 'userTask' && x.id === stepkey);
206
+ let fname = toSlug(ut?.formKey || wrapper.name || stepkey);
207
+ while (usedPageNames.has(fname)) fname += '_';
208
+ usedPageNames.add(fname);
209
+ const path = `pages/${fname}.yml`;
210
+ files[path] = yamlStringify(wrapper.page, { lineWidth: 0 });
211
+ pagesMap[stepkey] = { file: path, wrapper: omit(wrapper, ['page']) };
212
+ }
213
+
214
+ // structure.yaml — nested graph: edges under their source node, subProcess
215
+ // children nested, layout/waypoints inline, scriptTask bodies by ref.
216
+ // Annotations/associations carry their own geometry too, so the diagram is
217
+ // fully regenerable from structure.yaml alone (the manifest is never read
218
+ // back for the diagram).
219
+ const structure = {
220
+ process: model.process,
221
+ errors: model.errors,
222
+ nodes: nestNodes(model, null, scriptsMap, geo),
223
+ annotations: model.annotations.map((a) =>
224
+ geo.bounds[a.id] ? { ...a, layout: geo.bounds[a.id] } : a
225
+ ),
226
+ associations: model.associations.map((a) =>
227
+ geo.waypoints[a.id] ? { ...a, waypoints: geo.waypoints[a.id] } : a
228
+ ),
229
+ };
230
+ files[STRUCTURE_FILE] = stringifyStructure(structure);
231
+
232
+ // process.yaml — editable scalar overlay.
233
+ files[PROCESS_FILE] = yamlStringify(pick(payload, EDITABLE_SCALARS), { lineWidth: 0 });
234
+
235
+ // manifest — scalars/audit/ns + id<->file maps. The diagram is NOT stored:
236
+ // it is regenerated from structure.yaml geometry on recompose.
237
+ const manifest = {
238
+ guid: payload.guid,
239
+ document_id: payload.document_id,
240
+ process_name: payload.process_name,
241
+ scalars: omit(payload, ['built_page', 'pages', 'process_structure']),
242
+ process_structure: payload.process_structure,
243
+ ns,
244
+ scripts: scriptsMap,
245
+ pages: pagesMap,
246
+ };
247
+ files[MANIFEST_FILE] = JSON.stringify(manifest, null, 2);
248
+
249
+ return { files, manifest };
250
+ }
251
+
252
+ // ---------- recompose: read(path)->content -> payload ----------
253
+
254
+ function recompose(read) {
255
+ const manifest = JSON.parse(read(MANIFEST_FILE));
256
+ const structure = yamlParse(read(STRUCTURE_FILE));
257
+ const processOverlay = yamlParse(read(PROCESS_FILE)) || {};
258
+
259
+ const model = {
260
+ process: structure.process,
261
+ errors: structure.errors || [],
262
+ nodes: [],
263
+ edges: [],
264
+ annotations: structure.annotations || [],
265
+ associations: structure.associations || [],
266
+ };
267
+ const geo = { bounds: {}, waypoints: {}, expanded: {} };
268
+ flattenNodes(structure.nodes, null, model, read, geo);
269
+ // annotation/association geometry (top-level lists carry it inline)
270
+ for (const a of model.annotations) if (a.layout) geo.bounds[a.id] = a.layout;
271
+ for (const a of model.associations) if (a.waypoints) geo.waypoints[a.id] = a.waypoints;
272
+
273
+ const built_page = buildProcess({ ns: manifest.ns, model, geo });
274
+
275
+ const pages = [];
276
+ for (const info of Object.values(manifest.pages)) {
277
+ const pageObj = yamlParse(read(info.file));
278
+ pages.push({ ...info.wrapper, page: pageObj });
279
+ }
280
+
281
+ return {
282
+ ...manifest.scalars,
283
+ ...processOverlay,
284
+ built_page,
285
+ pages,
286
+ process_structure: manifest.process_structure,
287
+ };
288
+ }
289
+
290
+ // ---------- verify: decompose -> recompose -> semantic compare ----------
291
+ //
292
+ // 0-loss bar A: behavioral equivalence. Returns a list of human-readable
293
+ // differences (empty array == lossless round-trip).
294
+
295
+ function comparePayload(payload, rebuilt) {
296
+ const diffs = [];
297
+
298
+ // 1. logic graph (<process> subtree), formatting-insensitive
299
+ const procDiff = compareProcess(payload.built_page, rebuilt.built_page);
300
+ if (procDiff) diffs.push(`built_page <process>: ${procDiff}`);
301
+
302
+ // 2. diagram geometry — structural (drawing-order-insensitive, waypoints ordered)
303
+ const diaDiff = compareDiagram(payload.built_page, rebuilt.built_page);
304
+ if (diaDiff) diffs.push(`built_page <bpmndi>: ${diaDiff}`);
305
+
306
+ // 3. pages — deep-equal of page objects (jsonb semantics)
307
+ const pd = diff(payload.pages, rebuilt.pages, '$.pages');
308
+ if (pd) diffs.push(`pages: ${pd}`);
309
+
310
+ // 4. process_structure — verbatim
311
+ const psd = diff(payload.process_structure, rebuilt.process_structure, '$.process_structure');
312
+ if (psd) diffs.push(`process_structure: ${psd}`);
313
+
314
+ // 5. every other top-level scalar
315
+ const sd = diff(
316
+ omit(payload, ['built_page', 'pages', 'process_structure']),
317
+ omit(rebuilt, ['built_page', 'pages', 'process_structure']),
318
+ '$'
319
+ );
320
+ if (sd) diffs.push(`scalars: ${sd}`);
321
+
322
+ return diffs;
323
+ }
324
+
325
+ // ---------- push helpers ----------
326
+
327
+ // Deterministic JSON (recursively sorted keys) for stable checksums.
328
+ function stableStringify(value) {
329
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
330
+ if (value && typeof value === 'object') {
331
+ return `{${Object.keys(value)
332
+ .sort()
333
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
334
+ .join(',')}}`;
335
+ }
336
+ return JSON.stringify(value ?? null);
337
+ }
338
+
339
+ // Checksum of the recomposed payload — stable across runs, changes only when the
340
+ // local files change. clonePb stores this as checksum_at_pull; push recomputes it.
341
+ function localChecksum(read) {
342
+ return computeChecksum(stableStringify(recompose(read)));
343
+ }
344
+
345
+ // Enumerate the wizard's pages from the pages/*.yml files (the authoritative
346
+ // local set). Each page links to its userTask via `page._id.stepkey`; the
347
+ // manifest supplies the upstream page guid (absent => a locally-added page that
348
+ // must be created). `pageFiles` is the list of relative page file paths.
349
+ function enumeratePages(read, pageFiles) {
350
+ const manifest = JSON.parse(read(MANIFEST_FILE));
351
+ return pageFiles.map((file) => {
352
+ const page = yamlParse(read(file));
353
+ const stepkey = page?._id?.stepkey;
354
+ const info = manifest.pages?.[stepkey];
355
+ return {
356
+ stepkey,
357
+ file,
358
+ page,
359
+ guid: info?.wrapper?.guid ?? null,
360
+ wrapper: info?.wrapper ?? null,
361
+ };
362
+ });
363
+ }
364
+
365
+ // In-memory round-trip: decompose -> recompose from the produced file map -> compare.
366
+ function verifyRoundTrip(payload) {
367
+ const { files } = decompose(payload);
368
+ const read = (p) => {
369
+ if (!(p in files)) throw new Error(`recompose read missing file: ${p}`);
370
+ return files[p];
371
+ };
372
+ return comparePayload(payload, recompose(read));
373
+ }
374
+
375
+ export {
376
+ decompose,
377
+ recompose,
378
+ comparePayload,
379
+ verifyRoundTrip,
380
+ stableStringify,
381
+ localChecksum,
382
+ enumeratePages,
383
+ stringifyStructure,
384
+ pad,
385
+ MANIFEST_FILE,
386
+ STRUCTURE_FILE,
387
+ PROCESS_FILE,
388
+ EDITABLE_SCALARS,
389
+ toSlug,
390
+ };
@@ -0,0 +1,30 @@
1
+ // Disk IO for a decomposed process-builder project.
2
+
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'fs';
4
+ import { dirname, join } from 'path';
5
+
6
+ // Write a { path -> content } file map under baseDir. Content is written
7
+ // byte-exact (scripts must not be reformatted).
8
+ function writeProject(baseDir, files) {
9
+ for (const [rel, content] of Object.entries(files)) {
10
+ const full = join(baseDir, rel);
11
+ mkdirSync(dirname(full), { recursive: true });
12
+ writeFileSync(full, content, 'utf-8');
13
+ }
14
+ }
15
+
16
+ // A reader bound to baseDir, suitable for recompose().
17
+ function projectReader(baseDir) {
18
+ return (rel) => readFileSync(join(baseDir, rel), 'utf-8');
19
+ }
20
+
21
+ // Relative paths of the page files under <baseDir>/pages.
22
+ function listPageFiles(baseDir) {
23
+ const dir = join(baseDir, 'pages');
24
+ if (!existsSync(dir)) return [];
25
+ return readdirSync(dir)
26
+ .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
27
+ .map((f) => `pages/${f}`);
28
+ }
29
+
30
+ export { writeProject, projectReader, listPageFiles };
@@ -1,6 +1,7 @@
1
- import slugify from 'slugify';
2
- import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
2
  import { dirname } from 'path';
3
+ import slugify from 'slugify';
4
+ import { stringify as yamlStringify } from 'yaml';
4
5
 
5
6
  function toSlug(name) {
6
7
  return slugify(name, { lower: true, strict: true });
@@ -39,4 +40,23 @@ function fileExists(filePath) {
39
40
  return existsSync(filePath);
40
41
  }
41
42
 
42
- export { toSlug, defaultPhasePath, defaultMfaPath, ensureDir, writeCodeFile, readCodeFile, fileExists };
43
+ // Auto-generated, read-only context: dumps the workflow graph returned by the
44
+ // `agrippa_describe_workflow` MFA to `<dirPath>/workflow.yml`. Not tracked in the
45
+ // workspace -- it is not pushable, it is regenerated on every clone/pull.
46
+ function writeWorkflowDoc(dirPath, structure) {
47
+ const filePath = `${dirPath}/workflow.yml`;
48
+ ensureDir(filePath);
49
+ writeFileSync(filePath, yamlStringify(structure, { lineWidth: 0 }), 'utf-8');
50
+ return filePath;
51
+ }
52
+
53
+ export {
54
+ toSlug,
55
+ defaultPhasePath,
56
+ defaultMfaPath,
57
+ ensureDir,
58
+ writeCodeFile,
59
+ readCodeFile,
60
+ fileExists,
61
+ writeWorkflowDoc,
62
+ };
@@ -78,7 +78,8 @@ async function fetchActivePr(branch) {
78
78
  const res = await fetch(apiUrl, { headers: devopsHeaders() });
79
79
  const data = await res.json();
80
80
  if (!res.ok) throw new Error(`DevOps PR fetch failed: ${JSON.stringify(data)}`);
81
- if (!data.value || data.value.length === 0) throw new Error(`No active PR found for branch: ${branch}`);
81
+ if (!data.value || data.value.length === 0)
82
+ throw new Error(`No active PR found for branch: ${branch}`);
82
83
  return data.value[0];
83
84
  }
84
85
 
@@ -251,7 +252,9 @@ async function autoprAmend(options) {
251
252
  const content = readFileSync(changelogPath, 'utf-8');
252
253
  const existing = findLineByPrNumber(content, prNumber);
253
254
  if (!existing) {
254
- console.log(`Warning: no changelog line found for PR #${prNumber} — skipping changelog update`);
255
+ console.log(
256
+ `Warning: no changelog line found for PR #${prNumber} — skipping changelog update`
257
+ );
255
258
  return;
256
259
  }
257
260
  const lines = content.split('\n');
@@ -196,8 +196,13 @@ function appendRefsToLine(line, tridentIds, jiras) {
196
196
  const suffix = tridentIds.map((id) => `#${id}`).join(', ');
197
197
  const tridentMatch = result.match(/Trident (#[\d, #]+)/);
198
198
  if (tridentMatch) {
199
- const existingTridents = tridentMatch[1].replace(/[,\s]+$/, '');
200
- result = result.replace(/Trident (#[\d, #]+)/, `Trident ${existingTridents}, ${suffix}`);
199
+ const rawCapture = tridentMatch[1];
200
+ const trailing = rawCapture.match(/([,\s]+)$/)?.[1] ?? '';
201
+ const existingTridents = rawCapture.replace(/[,\s]+$/, '');
202
+ result = result.replace(
203
+ /Trident (#[\d, #]+)/,
204
+ `Trident ${existingTridents}, ${suffix}${trailing}`
205
+ );
201
206
  } else {
202
207
  const parenMatch = result.match(/\(([^)]*)\)\s*$/);
203
208
  if (parenMatch) {
@@ -1,8 +1,8 @@
1
- import { exportPb } from './exportPb.js';
2
- import { exportImperex } from './exportImperex.js';
3
1
  import { exportEmailTemplates } from './exportEmailTemplates.js';
4
- import { exportWorkflow } from './exportWorkflow.js';
2
+ import { exportImperex } from './exportImperex.js';
5
3
  import { exportLrp } from './exportLrp.js';
4
+ import { exportPb } from './exportPb.js';
5
+ import { exportWorkflow } from './exportWorkflow.js';
6
6
 
7
7
  function exportRip() {
8
8
  console.log('Not implemented yet.');