@waron97/prbot 3.4.0 → 3.5.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.
- package/README.md +53 -37
- package/package.json +1 -1
- package/src/agrippa/commands/diff.js +55 -17
- package/src/agrippa/commands/pb.js +4 -0
- package/src/agrippa/index.js +5 -2
- package/src/agrippa/lib/pbEdit.js +49 -2
- package/src/agrippa/lib/pbProject.js +21 -1
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ CLI tool for managing PRs, changelogs, and Odoo workflow XML files in the addons
|
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
7
|
+
Requires Node >= 20.
|
|
8
|
+
|
|
7
9
|
```bash
|
|
8
10
|
npm install -g @waron97/prbot
|
|
9
11
|
```
|
|
@@ -129,9 +131,11 @@ prbot export workflow --no-commit
|
|
|
129
131
|
|
|
130
132
|
Options:
|
|
131
133
|
|
|
132
|
-
| Flag
|
|
133
|
-
|
|
|
134
|
-
| `--no-commit`
|
|
134
|
+
| Flag | Description |
|
|
135
|
+
| -------------- | ------------------------------------------------------------- |
|
|
136
|
+
| `--no-commit` | Skip the git commit step |
|
|
137
|
+
| `-q, --quiet` | Suppress informational output — errors still fail the command |
|
|
138
|
+
| `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
|
|
135
139
|
|
|
136
140
|
### `prbot export pb`
|
|
137
141
|
|
|
@@ -144,9 +148,11 @@ prbot export pb --no-commit
|
|
|
144
148
|
|
|
145
149
|
Options:
|
|
146
150
|
|
|
147
|
-
| Flag
|
|
148
|
-
|
|
|
149
|
-
| `--no-commit`
|
|
151
|
+
| Flag | Description |
|
|
152
|
+
| -------------- | ------------------------------------------------------------- |
|
|
153
|
+
| `--no-commit` | Skip the git commit step |
|
|
154
|
+
| `-q, --quiet` | Suppress informational output — errors still fail the command |
|
|
155
|
+
| `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
|
|
150
156
|
|
|
151
157
|
### `prbot export imperex`
|
|
152
158
|
|
|
@@ -159,9 +165,11 @@ prbot export imperex --no-commit
|
|
|
159
165
|
|
|
160
166
|
Options:
|
|
161
167
|
|
|
162
|
-
| Flag
|
|
163
|
-
|
|
|
164
|
-
| `--no-commit`
|
|
168
|
+
| Flag | Description |
|
|
169
|
+
| -------------- | ------------------------------------------------------------- |
|
|
170
|
+
| `--no-commit` | Skip the git commit step |
|
|
171
|
+
| `-q, --quiet` | Suppress informational output — errors still fail the command |
|
|
172
|
+
| `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
|
|
165
173
|
|
|
166
174
|
### `prbot export email-templates`
|
|
167
175
|
|
|
@@ -174,9 +182,11 @@ prbot export email-templates --no-commit
|
|
|
174
182
|
|
|
175
183
|
Options:
|
|
176
184
|
|
|
177
|
-
| Flag
|
|
178
|
-
|
|
|
179
|
-
| `--no-commit`
|
|
185
|
+
| Flag | Description |
|
|
186
|
+
| -------------- | ------------------------------------------------------------- |
|
|
187
|
+
| `--no-commit` | Skip the git commit step |
|
|
188
|
+
| `-q, --quiet` | Suppress informational output — errors still fail the command |
|
|
189
|
+
| `-s, --silent` | Deprecated alias of `--quiet`; no longer swallows errors |
|
|
180
190
|
|
|
181
191
|
### `prbot init`
|
|
182
192
|
|
|
@@ -184,10 +194,11 @@ Interactive setup: writes `~/.config/prbot/config`.
|
|
|
184
194
|
|
|
185
195
|
### `prbot update`
|
|
186
196
|
|
|
187
|
-
Reinstalls
|
|
197
|
+
Reinstalls prbot from npm — latest by default, or a pinned version.
|
|
188
198
|
|
|
189
199
|
```bash
|
|
190
|
-
prbot update
|
|
200
|
+
prbot update # latest
|
|
201
|
+
prbot update 3.4.0 # pin an exact version
|
|
191
202
|
```
|
|
192
203
|
|
|
193
204
|
---
|
|
@@ -225,26 +236,27 @@ agrippa clone --lrp --name B2WA_ml_IFS_passive_trigger --path my-lrp/
|
|
|
225
236
|
|
|
226
237
|
Options:
|
|
227
238
|
|
|
228
|
-
| Flag
|
|
229
|
-
|
|
|
230
|
-
| `--phase`
|
|
231
|
-
| `--mfa`
|
|
232
|
-
| `--pb`
|
|
233
|
-
| `--lrp`
|
|
234
|
-
| `--id <id>`
|
|
235
|
-
| `--name <name>`
|
|
236
|
-
| `--path <path>`
|
|
239
|
+
| Flag | Description |
|
|
240
|
+
| --------------- | -------------------------------------------------------------------------- |
|
|
241
|
+
| `--phase` | Clone a phase (select a workflow) |
|
|
242
|
+
| `--mfa` | Clone an MFA record |
|
|
243
|
+
| `--pb` | Clone a process-builder wizard |
|
|
244
|
+
| `--lrp` | Clone a long-running process |
|
|
245
|
+
| `--id <id>` | Skip selection, clone by ID (phase/mfa) |
|
|
246
|
+
| `--name <name>` | Skip selection: `document_id` (with `--pb`) or process name (with `--lrp`) |
|
|
247
|
+
| `--path <path>` | Destination path (base dir for phases/wizard/lrp, file for MFA) |
|
|
237
248
|
|
|
238
249
|
### `agrippa pull`
|
|
239
250
|
|
|
240
|
-
Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). Lets you select which to pull.
|
|
251
|
+
Fetches remote code for all tracked entries and shows what changed. Classifies each as `fast-forward` (safe overwrite) or `conflict` (local edits would be lost). `conflict` entries are shown but **not** preselected — you opt in explicitly. Lets you select which to pull.
|
|
241
252
|
|
|
242
253
|
After pulling, also checks tracked workflows for newly added `from_code` phases and auto-clones any not yet present locally.
|
|
243
254
|
|
|
244
|
-
Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification (
|
|
255
|
+
Tracked **process-builder wizards** and **long-running processes** are also refreshed from upstream: the local project is re-decomposed from the latest payload (orphan script files pruned; PB wizards also prune orphan pages), with the same `fast-forward`/`conflict` classification — a semantic checksum of the recomposed payload (not raw `updated_date`), canonicalized so cosmetic noise never triggers a false conflict: whitespace-only BPMN text nodes, `format`-only `labelPos`, page ordering, and the `updated_date`/`modified_by` audit fields Odoo/Symple bump on any server touch are all excluded from the comparison. The current local state is backed up to `.backup/<timestamp>/<path>/local.json` first.
|
|
245
256
|
|
|
246
257
|
```bash
|
|
247
258
|
agrippa pull
|
|
259
|
+
agrippa pull --non-interactive # no prompts: auto-select fast-forward, fail if any conflict
|
|
248
260
|
```
|
|
249
261
|
|
|
250
262
|
### `agrippa push`
|
|
@@ -257,18 +269,21 @@ Pushing a wizard saves it as a **draft**; publish it so live consumers see the c
|
|
|
257
269
|
agrippa push # prompts whether to publish/deploy each pushed wizard/LRP
|
|
258
270
|
agrippa push --publish # auto-publish wizards, auto-deploy LRPs
|
|
259
271
|
agrippa push --skip-publish # never publish/deploy (no prompt)
|
|
272
|
+
agrippa push --non-interactive # no prompts: auto-select fast-forward, fail if any conflict;
|
|
273
|
+
# publish/deploy defaults to skip unless --publish is also passed
|
|
260
274
|
```
|
|
261
275
|
|
|
262
|
-
### `agrippa diff [
|
|
276
|
+
### `agrippa diff [target]`
|
|
263
277
|
|
|
264
|
-
Shows a diff between local files and remote code.
|
|
278
|
+
Shows a diff between local files and remote code. `[target]` optionally narrows it to a single file, a workflow folder, a cloned project directory, or a `document_id`/name; omit it for the whole workspace.
|
|
265
279
|
|
|
266
|
-
For process-builder wizards, diffs the whole project tree against what the workspace would look like if the
|
|
280
|
+
For process-builder wizards and long-running processes, diffs the whole project tree against what the workspace would look like if the project were decomposed fresh from upstream right now (`.backup/` and `preview.svg` are excluded as local-only artifacts).
|
|
267
281
|
|
|
268
282
|
```bash
|
|
269
283
|
agrippa diff
|
|
270
284
|
agrippa diff my-workflow/some-phase.py
|
|
271
285
|
agrippa diff ml_review_billing # a cloned wizard's directory
|
|
286
|
+
agrippa diff B2WA_M2C_passthrough # a cloned LRP, by directory or name
|
|
272
287
|
```
|
|
273
288
|
|
|
274
289
|
### `agrippa init-phase`
|
|
@@ -295,15 +310,16 @@ These commands exist mainly so an **AI agent** can add/remove/connect blocks wit
|
|
|
295
310
|
|
|
296
311
|
Newly added blocks get placeholder geometry. `pb format` re-lays-out the **whole** diagram (discarding any hand-tuned layout), so running it is a deliberate **human decision** — the alternative is positioning the new blocks by hand in the UI. Agents are expected to make structural edits but **not** to run `format` or to `pull`/`push`; a human reviews and syncs.
|
|
297
312
|
|
|
298
|
-
| Command | Purpose
|
|
299
|
-
| --------------- |
|
|
300
|
-
| `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout
|
|
301
|
-
| `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files
|
|
302
|
-
| `pb rm` | Remove a node (`--id`), its edges, and its script/page files
|
|
303
|
-
| `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule
|
|
304
|
-
| `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`)
|
|
305
|
-
| `pb
|
|
306
|
-
| `pb
|
|
313
|
+
| Command | Purpose |
|
|
314
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
315
|
+
| `pb format` | Re-lay-out the **entire** diagram (elkjs, left→right); overwrites existing layout |
|
|
316
|
+
| `pb add` | Add a node (`--type`, `--name`, `--parent`); scaffolds script/page files |
|
|
317
|
+
| `pb rm` | Remove a node (`--id`), its edges, and its script/page files |
|
|
318
|
+
| `pb connect` | Add a flow (`--from`, `--to`, `--condition`, `--default`); enforces the gateway rule |
|
|
319
|
+
| `pb disconnect` | Remove a flow (`--id`, or `--from`/`--to`); clears the source gateway's `default` if it pointed at that flow |
|
|
320
|
+
| `pb lint` | Check the structural rules: gateway defaults/conditions, branch names, incoming-flow limits, and `default`s pointing at flows that no longer exist |
|
|
321
|
+
| `pb ls` | List nodes and edges with their ids (discover targets without reading the YAML) |
|
|
322
|
+
| `pb preview` | Render the diagram to an SVG (`--out`) for a quick visual check |
|
|
307
323
|
|
|
308
324
|
```bash
|
|
309
325
|
agrippa pb ls --pb ml_review_billing
|
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import { getToken } from '../../lib/auth.js';
|
|
|
15
15
|
import { error, log } from '../../lib/logger.js';
|
|
16
16
|
import { computeChecksum } from '../lib/checksum.js';
|
|
17
17
|
import { loadEffectiveEnv, readConfig } from '../lib/config.js';
|
|
18
|
+
import { fetchUpstream } from '../lib/lrpApi.js';
|
|
18
19
|
import { getProcess } from '../lib/pbApi.js';
|
|
19
20
|
import { decompose, localChecksum } from '../lib/pbProject.js';
|
|
20
21
|
import { projectReader, writeProject } from '../lib/pbWorkspace.js';
|
|
@@ -36,13 +37,24 @@ async function diff(targetArg) {
|
|
|
36
37
|
return;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
// Decomposed projects (pb + lrp) diff as whole directory trees; phases and
|
|
41
|
+
// MFAs are single code files. An LRP left in the code bucket used to reach
|
|
42
|
+
// readCodeFile() with a directory path and blow up with EISDIR, so the
|
|
43
|
+
// split is by "is this a project" rather than "is this a wizard".
|
|
44
|
+
const projectEntries = entries.filter((e) => PROJECT_TYPES.has(e.object_type));
|
|
45
|
+
const codeEntries = entries.filter((e) => !PROJECT_TYPES.has(e.object_type));
|
|
41
46
|
|
|
42
47
|
if (codeEntries.length && !process.env.RIP_URL)
|
|
43
48
|
throw new Error('RIP_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
|
|
44
|
-
if (
|
|
49
|
+
if (projectEntries.some((e) => e.object_type === 'process_builder') && !process.env.PB_URL)
|
|
45
50
|
throw new Error('PB_URL is not configured. Run `prbot init` or set it in agrippa.yaml.');
|
|
51
|
+
if (
|
|
52
|
+
projectEntries.some((e) => e.object_type === 'long_running_process') &&
|
|
53
|
+
!process.env.IMPORTEXPORT_URL
|
|
54
|
+
)
|
|
55
|
+
throw new Error(
|
|
56
|
+
'IMPORTEXPORT_URL is not configured. Run `prbot init` or set it in agrippa.yaml.'
|
|
57
|
+
);
|
|
46
58
|
|
|
47
59
|
log('Fetching remote code...');
|
|
48
60
|
const token = await getToken();
|
|
@@ -57,8 +69,8 @@ async function diff(targetArg) {
|
|
|
57
69
|
chunks.push(...cs);
|
|
58
70
|
}
|
|
59
71
|
|
|
60
|
-
for (const entry of
|
|
61
|
-
const { hasDiff, chunk } = await
|
|
72
|
+
for (const entry of projectEntries) {
|
|
73
|
+
const { hasDiff, chunk } = await diffProjectEntry(token, entry);
|
|
62
74
|
if (hasDiff) {
|
|
63
75
|
diffCount++;
|
|
64
76
|
chunks.push(chunk);
|
|
@@ -124,14 +136,33 @@ function diffCodeEntries(entries, remoteCodeMap) {
|
|
|
124
136
|
return { diffCount, chunks };
|
|
125
137
|
}
|
|
126
138
|
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
139
|
+
// Decomposed project (process-builder wizard or long-running process): fetch
|
|
140
|
+
// the upstream payload, decompose it into a throwaway project tree (exactly
|
|
141
|
+
// what re-cloning now would produce) and diff it against a copy of the local
|
|
142
|
+
// project, recursively. `.backup/` and `preview.svg` are local-only artifacts
|
|
143
|
+
// (push backups, dev preview render) with no upstream counterpart, so they're
|
|
144
|
+
// stripped from the local copy before diffing.
|
|
145
|
+
//
|
|
146
|
+
// The two types differ only in how upstream is reached: a wizard by guid, an
|
|
147
|
+
// LRP by name (its tabulator id changes on every save — never a stable key,
|
|
148
|
+
// see pullLrpEntry). Everything downstream is the shared decompose pipeline.
|
|
149
|
+
const PROJECT_TYPES = new Set(['process_builder', 'long_running_process']);
|
|
150
|
+
|
|
151
|
+
async function fetchProjectUpstream(token, entry) {
|
|
152
|
+
if (entry.object_type === 'long_running_process') {
|
|
153
|
+
const res = await fetchUpstream(token, entry.name);
|
|
154
|
+
if (!res?.payload) throw new Error(`could not fetch upstream LRP "${entry.name}"`);
|
|
155
|
+
return res.payload;
|
|
156
|
+
}
|
|
133
157
|
const upstream = await getProcess(token, entry.guid);
|
|
134
158
|
if (!upstream) throw new Error(`could not fetch upstream wizard ${entry.guid}`);
|
|
159
|
+
return upstream;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function diffProjectEntry(token, entry) {
|
|
163
|
+
const upstream = await fetchProjectUpstream(token, entry);
|
|
164
|
+
const label =
|
|
165
|
+
entry.object_type === 'long_running_process' ? 'long-running process' : 'process-builder';
|
|
135
166
|
|
|
136
167
|
const tmpRoot = mkdtempSync(join(tmpdir(), 'agrippa-pb-diff-'));
|
|
137
168
|
try {
|
|
@@ -159,7 +190,7 @@ async function diffPbEntry(token, entry) {
|
|
|
159
190
|
if (result.status !== null && result.status > 1) {
|
|
160
191
|
error(`git diff failed for ${entry.path}`);
|
|
161
192
|
}
|
|
162
|
-
const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (
|
|
193
|
+
const header = Buffer.from(`\n=== ${entry.path} [${entry.name}] (${label}) ===\n`);
|
|
163
194
|
const chunk = Buffer.concat([header, result.stdout ?? Buffer.alloc(0)]);
|
|
164
195
|
return { hasDiff: true, chunk };
|
|
165
196
|
} finally {
|
|
@@ -173,14 +204,21 @@ function filterEntries(workspace, targetArg) {
|
|
|
173
204
|
// Normalise: strip trailing slash
|
|
174
205
|
const target = targetArg.replace(/\/$/, '');
|
|
175
206
|
|
|
176
|
-
// If it exists on disk and is a directory,
|
|
207
|
+
// If it exists on disk and is a directory, take both the entries *under* it
|
|
208
|
+
// (a workflow folder holding one file per phase) and any entry that *is* it
|
|
209
|
+
// — a pb/lrp project is tracked as the directory itself, so prefix matching
|
|
210
|
+
// alone silently returned nothing for the most common target.
|
|
177
211
|
if (existsSync(target) && statSync(target).isDirectory()) {
|
|
178
|
-
const prefix = target
|
|
179
|
-
|
|
212
|
+
const prefix = target + '/';
|
|
213
|
+
const matches = workspace.filter((e) => e.path === target || e.path.startsWith(prefix));
|
|
214
|
+
if (matches.length) return matches;
|
|
180
215
|
}
|
|
181
216
|
|
|
182
|
-
//
|
|
183
|
-
|
|
217
|
+
// Exact file path (whether it exists yet or not), else fall back to the
|
|
218
|
+
// identifiers the other commands accept — document_id (`--pb`) and name.
|
|
219
|
+
const byPath = workspace.filter((e) => e.path === target);
|
|
220
|
+
if (byPath.length) return byPath;
|
|
221
|
+
return workspace.filter((e) => e.document_id === target || e.name === target);
|
|
184
222
|
}
|
|
185
223
|
|
|
186
224
|
export { diff };
|
|
@@ -218,6 +218,8 @@ async function pbRemove(opts) {
|
|
|
218
218
|
`Removed ${result.removed.length} node(s) [${result.removed.join(', ')}], ` +
|
|
219
219
|
`${result.removedEdges} dangling edge(s), ${deletes.length} file(s).`
|
|
220
220
|
);
|
|
221
|
+
for (const c of result.clearedDefaults || [])
|
|
222
|
+
log(` cleared dangling default ${c.edge} on ${c.node}.`);
|
|
221
223
|
}
|
|
222
224
|
|
|
223
225
|
async function pbConnect(opts) {
|
|
@@ -250,6 +252,8 @@ async function pbDisconnect(opts) {
|
|
|
250
252
|
saveStructure(dir, structure);
|
|
251
253
|
validate(dir);
|
|
252
254
|
log(`Removed ${result.removed} edge(s)${result.id ? ` (${result.id})` : ''}.`);
|
|
255
|
+
for (const c of result.clearedDefaults || [])
|
|
256
|
+
log(` cleared dangling default ${c.edge} on ${c.node}.`);
|
|
253
257
|
}
|
|
254
258
|
|
|
255
259
|
async function pbSetDefault(opts) {
|
package/src/agrippa/index.js
CHANGED
|
@@ -91,8 +91,11 @@ program
|
|
|
91
91
|
);
|
|
92
92
|
|
|
93
93
|
program
|
|
94
|
-
.command('diff [
|
|
95
|
-
.description(
|
|
94
|
+
.command('diff [target]')
|
|
95
|
+
.description(
|
|
96
|
+
'Show differences between local files and remote code. [target] = file, folder, ' +
|
|
97
|
+
'project dir, document_id or name; omit for the whole workspace'
|
|
98
|
+
)
|
|
96
99
|
.action((path) =>
|
|
97
100
|
diff(path).catch((err) => {
|
|
98
101
|
error(`Error: ${err.message}`);
|
|
@@ -317,7 +317,13 @@ function removeNode(structure, manifest, { id }) {
|
|
|
317
317
|
);
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
-
|
|
320
|
+
const clearedDefaults = clearDanglingDefaults(structure);
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
writes: {},
|
|
324
|
+
deletes,
|
|
325
|
+
result: { removed: [...victimIds], removedEdges, clearedDefaults },
|
|
326
|
+
};
|
|
321
327
|
}
|
|
322
328
|
|
|
323
329
|
// ---------- connect / disconnect ----------
|
|
@@ -492,15 +498,55 @@ function lintIncomingEdges(structure) {
|
|
|
492
498
|
return issues;
|
|
493
499
|
}
|
|
494
500
|
|
|
501
|
+
// Flag `attrs.default` values that don't name an outgoing edge of their own
|
|
502
|
+
// node. lintGateways only covers the inverse (a gateway that needs a default
|
|
503
|
+
// and hasn't got a valid one), so a node left with a single outgoing flow and a
|
|
504
|
+
// default pointing at a deleted one used to pass clean — and then fail at
|
|
505
|
+
// publish with Activiti's null-reference NPE. The edits that delete edges now
|
|
506
|
+
// clear these themselves (clearDanglingDefaults); this rule is the net for
|
|
507
|
+
// hand-edited structure.yaml and for projects predating that fix.
|
|
508
|
+
function lintDanglingDefaults(structure) {
|
|
509
|
+
const issues = [];
|
|
510
|
+
eachNode(structure.nodes, null, (n) => {
|
|
511
|
+
const def = n.attrs?.default;
|
|
512
|
+
if (!def) return;
|
|
513
|
+
if ((n.edges || []).some((e) => e.id === def)) return;
|
|
514
|
+
issues.push(
|
|
515
|
+
`${n.id} (${n.name || n.type}): default flow ${def} does not exist on this node — ` +
|
|
516
|
+
`Activiti fails the deploy on the dangling reference.`
|
|
517
|
+
);
|
|
518
|
+
});
|
|
519
|
+
return issues;
|
|
520
|
+
}
|
|
521
|
+
|
|
495
522
|
// Run all lint rules and return combined issues.
|
|
496
523
|
function lintAll(structure) {
|
|
497
524
|
return [
|
|
498
525
|
...lintGateways(structure),
|
|
526
|
+
...lintDanglingDefaults(structure),
|
|
499
527
|
...lintEdgeNames(structure),
|
|
500
528
|
...lintIncomingEdges(structure),
|
|
501
529
|
];
|
|
502
530
|
}
|
|
503
531
|
|
|
532
|
+
// Drop every `attrs.default` that no longer names an outgoing edge of its own
|
|
533
|
+
// node. Must run after any edit that deletes edges: a default referencing a
|
|
534
|
+
// removed flow recomposes fine and passes the other lint rules, but Activiti
|
|
535
|
+
// resolves the reference at deploy time, gets null, and the publish fails with
|
|
536
|
+
// an unattributed `Could not deploy the XML: null. Error: null`. Returns the
|
|
537
|
+
// cleared [nodeId, edgeId] pairs so callers can report them.
|
|
538
|
+
function clearDanglingDefaults(structure) {
|
|
539
|
+
const cleared = [];
|
|
540
|
+
eachNode(structure.nodes, null, (n) => {
|
|
541
|
+
const def = n.attrs?.default;
|
|
542
|
+
if (!def) return;
|
|
543
|
+
if ((n.edges || []).some((e) => e.id === def)) return;
|
|
544
|
+
delete n.attrs.default;
|
|
545
|
+
cleared.push({ node: n.id, edge: def });
|
|
546
|
+
});
|
|
547
|
+
return cleared;
|
|
548
|
+
}
|
|
549
|
+
|
|
504
550
|
// Remove an edge by id, or by --from/--to pair.
|
|
505
551
|
function disconnect(structure, { id, from, to }) {
|
|
506
552
|
let removed = 0;
|
|
@@ -516,7 +562,8 @@ function disconnect(structure, { id, from, to }) {
|
|
|
516
562
|
removed += before - n.edges.length;
|
|
517
563
|
});
|
|
518
564
|
if (!removed) throw new Error(id ? `edge not found: ${id}` : `no edge from ${from} to ${to}`);
|
|
519
|
-
|
|
565
|
+
const clearedDefaults = clearDanglingDefaults(structure);
|
|
566
|
+
return { writes: {}, deletes: [], result: { removed, id: removedId, clearedDefaults } };
|
|
520
567
|
}
|
|
521
568
|
|
|
522
569
|
// ---------- list ----------
|
|
@@ -391,9 +391,29 @@ function stableStringify(value) {
|
|
|
391
391
|
// to resync.
|
|
392
392
|
const VOLATILE_AUDIT_FIELDS = ['updated_date', 'modified_by'];
|
|
393
393
|
|
|
394
|
+
// `status`/`version` are the server's publication lifecycle, not content —
|
|
395
|
+
// excluded for the same reason as the audit fields above, but only at the top
|
|
396
|
+
// level (page wrappers carry neither). They surface in process.yaml via
|
|
397
|
+
// EDITABLE_SCALARS and are still sent on push; they just don't count as a
|
|
398
|
+
// change. Every PATCH flips the wizard server-side to draft/modified while
|
|
399
|
+
// process.yaml keeps whatever status was recorded at the last clone/pull, and
|
|
400
|
+
// push stores the *local* checksum as the new baseline — so a push not
|
|
401
|
+
// followed by a publish guaranteed a phantom `conflict` on the next push,
|
|
402
|
+
// with zero content difference on either side (observed live 2026-07-21 on
|
|
403
|
+
// ml_voltura_fibra_data_input: upstream came back `modified` against a
|
|
404
|
+
// `published` process.yaml, shifting the remote checksum off the baseline on
|
|
405
|
+
// its own). `version` is worse still: a publish that bumps it server-side
|
|
406
|
+
// pins the object to `conflict` permanently, since nothing local resyncs it.
|
|
407
|
+
const VOLATILE_LIFECYCLE_FIELDS = ['status', 'version'];
|
|
408
|
+
|
|
394
409
|
function canonicalForChecksum(payload) {
|
|
395
410
|
const { process, decls } = normalizeProcessTree(payload.built_page);
|
|
396
|
-
const rest = omit(payload, [
|
|
411
|
+
const rest = omit(payload, [
|
|
412
|
+
'built_page',
|
|
413
|
+
'pages',
|
|
414
|
+
...VOLATILE_AUDIT_FIELDS,
|
|
415
|
+
...VOLATILE_LIFECYCLE_FIELDS,
|
|
416
|
+
]);
|
|
397
417
|
// Page order is not a stable identity — locally it's whatever order the
|
|
398
418
|
// manifest happened to record at last decompose, while a live upstream
|
|
399
419
|
// fetch can return the same set of pages in a different order (observed
|