@pipefy/pipefy-process-coder 0.1.0 → 0.1.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 (159) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +465 -456
  20. package/README.md +825 -807
  21. package/bin/pipe.js +25 -4
  22. package/dist/apply/adopt.js +128 -0
  23. package/dist/apply/agentops.js +42 -0
  24. package/dist/apply/compile.js +722 -0
  25. package/dist/apply/execute.js +277 -0
  26. package/dist/apply/flowops.js +293 -0
  27. package/dist/apply/idmap.js +233 -0
  28. package/dist/apply/mutations.js +704 -0
  29. package/dist/apply/registry.js +348 -0
  30. package/dist/apply/types.js +18 -0
  31. package/dist/cli/args.js +79 -0
  32. package/dist/cli.js +187 -0
  33. package/dist/codec/flow.js +164 -0
  34. package/dist/codec/pack.js +88 -0
  35. package/dist/codec/roundtrip.js +86 -0
  36. package/dist/codec/unpack.js +181 -0
  37. package/dist/commands/agents.js +145 -0
  38. package/dist/commands/apply.js +982 -0
  39. package/dist/commands/context.js +79 -0
  40. package/dist/commands/create.js +63 -0
  41. package/dist/commands/diff.js +294 -0
  42. package/dist/commands/flows.js +348 -0
  43. package/dist/commands/misc.js +528 -0
  44. package/dist/commands/plan.js +301 -0
  45. package/dist/commands/pull.js +511 -0
  46. package/dist/commands/runs.js +74 -0
  47. package/dist/commands/skills.js +131 -0
  48. package/dist/commands/verify.js +205 -0
  49. package/dist/config.js +125 -0
  50. package/dist/diff/agents.js +80 -0
  51. package/dist/diff/diff.js +983 -0
  52. package/dist/diff/flow.js +258 -0
  53. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  54. package/dist/diff/render.js +90 -0
  55. package/dist/model/payload.js +84 -0
  56. package/dist/model/tree.js +7 -0
  57. package/dist/model/volatile.js +54 -0
  58. package/dist/pipefy/agents.js +122 -0
  59. package/dist/pipefy/automations.js +155 -0
  60. package/dist/pipefy/capability.js +84 -0
  61. package/dist/pipefy/client.js +204 -0
  62. package/dist/pipefy/discovery.js +148 -0
  63. package/dist/pipefy/internal.js +315 -0
  64. package/dist/pipefy/ipaas.js +269 -0
  65. package/dist/pipefy/reconstruct.js +674 -0
  66. package/dist/pipefy/reference.js +172 -0
  67. package/dist/pipefy/snapshot.js +185 -0
  68. package/dist/pipefy/toolkit.js +158 -0
  69. package/dist/report/integrations.js +165 -0
  70. package/dist/report/run.js +355 -0
  71. package/dist/util/fsx.js +50 -0
  72. package/dist/util/git.js +25 -0
  73. package/dist/util/json.js +56 -0
  74. package/dist/util/log.js +69 -0
  75. package/dist/util/pool.js +42 -0
  76. package/dist/util/slug.js +23 -0
  77. package/dist/util/tui.js +287 -0
  78. package/dist/validate/index.js +86 -0
  79. package/dist/validate/integrity.js +370 -0
  80. package/dist/validate/reference.js +120 -0
  81. package/dist/validate/schema.js +278 -0
  82. package/dist/workspace/agents.js +245 -0
  83. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  84. package/dist/workspace/flows.js +149 -0
  85. package/dist/workspace/layout.js +146 -0
  86. package/dist/workspace/lock.js +83 -0
  87. package/dist/workspace/read.js +168 -0
  88. package/dist/workspace/reference.js +24 -0
  89. package/dist/workspace/stamp.js +234 -0
  90. package/dist/workspace/write.js +194 -0
  91. package/package.json +37 -35
  92. package/src/apply/adopt.ts +0 -150
  93. package/src/apply/agentops.ts +0 -71
  94. package/src/apply/compile.ts +0 -875
  95. package/src/apply/execute.ts +0 -336
  96. package/src/apply/flowops.ts +0 -399
  97. package/src/apply/idmap.ts +0 -241
  98. package/src/apply/mutations.ts +0 -955
  99. package/src/apply/registry.ts +0 -430
  100. package/src/apply/types.ts +0 -134
  101. package/src/cli/args.ts +0 -88
  102. package/src/cli.ts +0 -211
  103. package/src/codec/flow.ts +0 -199
  104. package/src/codec/pack.ts +0 -103
  105. package/src/codec/roundtrip.ts +0 -94
  106. package/src/codec/unpack.ts +0 -198
  107. package/src/commands/agents.ts +0 -184
  108. package/src/commands/apply.ts +0 -1144
  109. package/src/commands/context.ts +0 -119
  110. package/src/commands/create.ts +0 -79
  111. package/src/commands/diff.ts +0 -314
  112. package/src/commands/flows.ts +0 -414
  113. package/src/commands/misc.ts +0 -644
  114. package/src/commands/plan.ts +0 -331
  115. package/src/commands/pull.ts +0 -567
  116. package/src/commands/runs.ts +0 -83
  117. package/src/commands/skills.ts +0 -137
  118. package/src/commands/verify.ts +0 -253
  119. package/src/config.ts +0 -168
  120. package/src/diff/agents.ts +0 -122
  121. package/src/diff/diff.ts +0 -1130
  122. package/src/diff/flow.ts +0 -318
  123. package/src/diff/render.ts +0 -101
  124. package/src/model/payload.ts +0 -154
  125. package/src/model/tree.ts +0 -99
  126. package/src/model/volatile.ts +0 -55
  127. package/src/pipefy/agents.ts +0 -165
  128. package/src/pipefy/automations.ts +0 -219
  129. package/src/pipefy/capability.ts +0 -119
  130. package/src/pipefy/client.ts +0 -267
  131. package/src/pipefy/discovery.ts +0 -209
  132. package/src/pipefy/internal.ts +0 -380
  133. package/src/pipefy/ipaas.ts +0 -365
  134. package/src/pipefy/reconstruct.ts +0 -775
  135. package/src/pipefy/reference.ts +0 -251
  136. package/src/pipefy/snapshot.ts +0 -245
  137. package/src/pipefy/toolkit.ts +0 -200
  138. package/src/report/integrations.ts +0 -231
  139. package/src/report/run.ts +0 -475
  140. package/src/util/fsx.ts +0 -45
  141. package/src/util/git.ts +0 -32
  142. package/src/util/json.ts +0 -55
  143. package/src/util/log.ts +0 -76
  144. package/src/util/pool.ts +0 -48
  145. package/src/util/slug.ts +0 -26
  146. package/src/util/tui.ts +0 -335
  147. package/src/validate/index.ts +0 -123
  148. package/src/validate/integrity.ts +0 -387
  149. package/src/validate/reference.ts +0 -136
  150. package/src/validate/schema.ts +0 -328
  151. package/src/workspace/agents.ts +0 -290
  152. package/src/workspace/flows.ts +0 -191
  153. package/src/workspace/layout.ts +0 -165
  154. package/src/workspace/lock.ts +0 -148
  155. package/src/workspace/read.ts +0 -165
  156. package/src/workspace/reference.ts +0 -24
  157. package/src/workspace/stamp.ts +0 -301
  158. package/src/workspace/write.ts +0 -225
  159. /package/{src → dist}/pipefy/toolkit_bearer.py +0 -0
@@ -0,0 +1,983 @@
1
+ import { canonicalString } from "../util/json.js";
2
+ import { VOLATILE_KEYS } from "../model/volatile.js";
3
+ import { ENTITIES } from "../model/payload.js";
4
+ // ── comparison ───────────────────────────────────────────────────────────────
5
+ const IGNORED_IN_COMPARISON = new Set([...VOLATILE_KEYS, 'id', 'repo_id', 'organization_id', 'organization_uuid']);
6
+ /**
7
+ * Keys holding an id, compared as strings.
8
+ *
9
+ * Pipefy is inconsistent about whether an id is `12345` or `"12345"` —
10
+ * the payload uses numbers, the APIs mostly strings — and an id is the same id
11
+ * either way. Comparing them typed reported changes on values that matched.
12
+ */
13
+ const ID_VALUED_KEYS = new Set([
14
+ 'phase_id', 'field_id', 'to_phase_id', 'from_phase_id', 'in_phase_id', 'card_id',
15
+ 'title_field_id', 'connected_pipe_id', 'source_id', 'owner_id', 'field_address',
16
+ 'target_phase_id', 'source_phase_id', 'action_repo_id', 'event_repo_id',
17
+ 'submitter_email_field_id', 'email_template_id', 'field_condition_id',
18
+ ]);
19
+ const looseId = (v) => typeof v === 'number' ? String(v) : v;
20
+ /**
21
+ * Text columns where the endpoint uses `null` and `""` interchangeably.
22
+ *
23
+ * Kept to a named set rather than applied to every string column, because
24
+ * elsewhere the difference between "unset" and "set to empty" can carry meaning.
25
+ * These four were measured returning both spellings from the same read.
26
+ */
27
+ const EMPTY_EQUIVALENT = new Set(['description', 'help', 'custom_validation', 'index_name']);
28
+ const isBlank = (v) => v === null || v === undefined || v === '';
29
+ const comparable = (row, extraIgnore = new Set()) => {
30
+ const out = {};
31
+ for (const [k, v] of Object.entries(row)) {
32
+ if (IGNORED_IN_COMPARISON.has(k) || extraIgnore.has(k) || k.startsWith('_'))
33
+ continue;
34
+ out[k] = ID_VALUED_KEYS.has(k) ? looseId(v) : v;
35
+ }
36
+ return out;
37
+ };
38
+ const NO_PARTIAL = { before: false, after: false };
39
+ /**
40
+ * Columns the server mints, which no create path accepts.
41
+ *
42
+ * When verifying intent, a null on the authored side of one of these is the
43
+ * absence of an opinion, not a request to clear it: `createPhaseField` derives
44
+ * the slug from the label, `createPhase` picks a `color_uuid`, and neither takes
45
+ * the value as input. Comparing them turned 60 successful creates into 60
46
+ * reported failures. A value the author *did* write is still asserted.
47
+ */
48
+ const SERVER_MINTED = new Set(['slug', 'color_uuid', 'suid']);
49
+ /**
50
+ * Column names the payload never uses, per entity.
51
+ *
52
+ * `createPipeRelation` names three of its flags `canCreateNewItems`,
53
+ * `canConnectExistingItems` and `canConnectMultipleItems`; the payload row
54
+ * spells the same three `can_create_connected_cards`, `can_search_connected_cards`
55
+ * and `can_connect_multiple_cards`. An author who writes the mutation's spelling
56
+ * — the one the API documents — gets a row whose keys no read can ever produce,
57
+ * and comparing them reported three differences on a relation that was exactly
58
+ * right. They are aliases, dropped from comparison; the payload spellings beside
59
+ * them are compared normally, because those the payload does carry.
60
+ */
61
+ const UNREADABLE_IN_PAYLOAD = {
62
+ pipe_relations: new Set([
63
+ 'can_create_new_items',
64
+ 'can_connect_existing_items',
65
+ 'can_connect_multiple_items',
66
+ ]),
67
+ };
68
+ /**
69
+ * Two jump target lists, compared as the sets they are.
70
+ *
71
+ * `PUT /internal_api/settings/phases/:id` replaces the whole set (PLAN.md §2.1)
72
+ * and answers in whatever order it likes, so ordering carries no meaning on
73
+ * either side. Comparing them as ordered lists reported a phase whose jumps were
74
+ * exactly right as needing another write — every single run.
75
+ */
76
+ const sameIdSet = (x, y) => {
77
+ const norm = (v) => JSON.stringify((Array.isArray(v) ? v : [])
78
+ .map((n) => String(n))
79
+ .sort());
80
+ return norm(x) === norm(y);
81
+ };
82
+ /**
83
+ * `intentOnly` compares only the columns the *after* side actually states.
84
+ *
85
+ * This is what verification means. A newly authored entity is a partial
86
+ * specification: the author writes the columns they care about and the server
87
+ * fills the rest — `slug`, `card_synced`, `unique`, the connector flags. Asking
88
+ * "are these rows identical" then fails a perfectly correct apply, reporting
89
+ * seven differences per created field, all of them defaults the author never
90
+ * spoke about. Asking "does the live pipe satisfy the intent" is both the useful
91
+ * question and the answerable one.
92
+ *
93
+ * Off for the ordinary edit-loop diff, where both sides come from full reads and
94
+ * an absent column really is information.
95
+ */
96
+ const deltasOf = (before, after, extraIgnore, partial = NO_PARTIAL, intentOnly = false) => {
97
+ const a = comparable(before, extraIgnore);
98
+ const b = comparable(after, extraIgnore);
99
+ const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
100
+ const out = [];
101
+ for (const k of keys) {
102
+ if (intentOnly && !(k in b))
103
+ continue;
104
+ /**
105
+ * A client-minted uuid on a new entity is a local handle, not a request.
106
+ * `CreatePhaseFieldInput` has no `uuid` field — the introspection filter drops
107
+ * it and the server mints its own — so verification must not assert a value
108
+ * that was never sent. The post-apply sync replaces the handle with the real
109
+ * uuid, and from then on it is compared normally.
110
+ */
111
+ /**
112
+ * A uuid is never asserted when verifying intent.
113
+ *
114
+ * No create path on this endpoint accepts one — `CreatePhaseFieldInput` and
115
+ * the internal `createAutomation` both lack the field, and the server mints
116
+ * its own — so a uuid is always server-owned and never something the tool
117
+ * asked for. It also cannot survive the recreate strategy: delete + create
118
+ * is the only automation update available, and it necessarily yields a new
119
+ * uuid, which verification would otherwise report as a failed apply.
120
+ */
121
+ if (intentOnly && k === 'uuid')
122
+ continue;
123
+ /**
124
+ * Nor is a slug the author never wrote.
125
+ *
126
+ * `CreatePhaseFieldInput` has no slug either: the server derives one from the
127
+ * label. A field authored by hand therefore says `"slug": null`, and after a
128
+ * successful create the pipe says `"slug": "request_title"` — which
129
+ * verification would report as 55 failed field creates, all of which
130
+ * actually succeeded. A slug the author *did* write is still asserted, since
131
+ * automations and conditions can legitimately reference one.
132
+ */
133
+ if (intentOnly && SERVER_MINTED.has(k) && (b[k] === null || b[k] === undefined))
134
+ continue;
135
+ const aRaw = a[k] ?? null;
136
+ const bRaw = b[k] ?? null;
137
+ /**
138
+ * On a text column, `null` and `""` are the same absence.
139
+ *
140
+ * Both spellings come back from the same endpoint for the same column, on
141
+ * the same repo — measured across ten fields of three databases:
142
+ * `description` was `""` nine times and `null` once, `help` the same, and
143
+ * `custom_validation` `""` eight times and `null` twice. So a field whose
144
+ * file says `""` verifies against a pipe that says `null` and reports
145
+ * `custom_validation: null -> ""`, which is a difference nobody can act on
146
+ * and which blocks the file sync.
147
+ *
148
+ * Only the empty pair collapses. Clearing a real value to `""` still differs
149
+ * from that value, which is the change an author actually makes.
150
+ */
151
+ if (EMPTY_EQUIVALENT.has(k) && isBlank(aRaw) && isBlank(bRaw))
152
+ continue;
153
+ const av = canonicalString(ID_VALUED_KEYS.has(k) ? looseId(aRaw) : aRaw);
154
+ const bv = canonicalString(ID_VALUED_KEYS.has(k) ? looseId(bRaw) : bRaw);
155
+ if (av === bv)
156
+ continue;
157
+ /**
158
+ * An unresolved placeholder in the intent was a request to substitute, and
159
+ * the substitution is what the pipe now holds. Verify cannot assert the id
160
+ * the server chose unless it has the plan's id map (which `pipe apply`
161
+ * passes in), so a placeholder is treated as satisfied.
162
+ */
163
+ if (intentOnly && typeof bRaw === 'string' && bRaw.includes('%{_new:'))
164
+ continue;
165
+ if (intentOnly && JSON.stringify(bRaw ?? '').includes('%{_new:'))
166
+ continue;
167
+ if (partial.after && bRaw === null && aRaw !== null)
168
+ continue;
169
+ if (partial.before && aRaw === null && bRaw !== null)
170
+ continue;
171
+ out.push({ key: k, before: aRaw, after: bRaw });
172
+ }
173
+ return out;
174
+ };
175
+ /** Owned rows have no identity, so they are compared as sorted sets. */
176
+ const setOf = (rows) => rows.map((r) => canonicalString(comparable(r))).sort();
177
+ const sameSet = (a, b) => canonicalString(setOf(a)) === canonicalString(setOf(b));
178
+ /**
179
+ * Whether a nested collection differs *informatively*.
180
+ *
181
+ * The collection counterpart of the column and row rules. Measured: the internal
182
+ * automations endpoint returns no `field_map` for four `update_card_field`
183
+ * automations a snapshot shows five entries for, and no `condition` for one that
184
+ * has one. Reported literally, that is "empty every field map" — so an empty
185
+ * collection on a partial side is unseen, not emptied.
186
+ */
187
+ const hasPlaceholder = (v) => JSON.stringify(v ?? '').includes('%{_new:');
188
+ /**
189
+ * A nested structure, normalised for comparison at every depth.
190
+ *
191
+ * `comparable` only ever reached the top level of a row, so a nested collection
192
+ * was compared raw. That made a freshly created field condition impossible to
193
+ * verify: the server mints an `id` on the condition and on every expression, and
194
+ * stores `expressions_structure` leaves as strings where the workspace writes
195
+ * numbers, so the read-back never equalled the intent no matter what was sent.
196
+ *
197
+ * Numbers become strings here, at every depth, for the same reason `looseId`
198
+ * exists: this payload is inconsistent about `0` versus `"0"` and they are the
199
+ * same value. Applied only to nested structures, so column deltas keep comparing
200
+ * types as they always did.
201
+ */
202
+ const nestedComparable = (v) => {
203
+ if (Array.isArray(v))
204
+ return v.map(nestedComparable);
205
+ if (v && typeof v === 'object') {
206
+ const out = {};
207
+ for (const [k, val] of Object.entries(v)) {
208
+ if (IGNORED_IN_COMPARISON.has(k) || k.startsWith('_'))
209
+ continue;
210
+ out[k] = nestedComparable(val);
211
+ }
212
+ return out;
213
+ }
214
+ return typeof v === 'number' ? String(v) : v;
215
+ };
216
+ /**
217
+ * The live side, reduced to what the intent side actually states — at every depth.
218
+ *
219
+ * `deltasOf` already skips a column the intent does not mention (`!(k in b)`).
220
+ * That rule stopped at the top level, so a nested key the workspace never wrote
221
+ * still failed verification: `phase_id` on a field condition action, and
222
+ * `structure_id` on an expression, are both added by the server.
223
+ *
224
+ * Array elements are reduced against the union of the keys their intent
225
+ * counterparts state, rather than pairwise by position, so element order cannot
226
+ * mis-pair them. That can only relax the comparison, never tighten it — the
227
+ * outer comparison still sorts, so a real difference in the values still shows.
228
+ */
229
+ const asStated = (live, intent) => {
230
+ if (Array.isArray(live)) {
231
+ const peers = Array.isArray(intent) ? intent : [];
232
+ const merged = peers.reduce((acc, e) => (e && typeof e === 'object' && !Array.isArray(e) ? { ...acc, ...e } : acc), {});
233
+ const shape = Object.keys(merged).length ? merged : peers[0];
234
+ return live.map((l) => asStated(l, shape));
235
+ }
236
+ if (live && typeof live === 'object' && intent && typeof intent === 'object' && !Array.isArray(intent)) {
237
+ const out = {};
238
+ for (const [k, val] of Object.entries(live)) {
239
+ if (!(k in intent))
240
+ continue;
241
+ out[k] = asStated(val, intent[k]);
242
+ }
243
+ return out;
244
+ }
245
+ return live;
246
+ };
247
+ const nestedSame = (a, b, intentOnly) => {
248
+ const left = intentOnly ? asStated(a, b) : a;
249
+ return canonicalString(nestedComparable(left)) === canonicalString(nestedComparable(b));
250
+ };
251
+ const nestedDiffers = (a, b, partial, intentOnly = false) => {
252
+ if (sameSet(a, b))
253
+ return false;
254
+ const norm = (rows) => canonicalString(nestedComparable(rows).map(canonicalString).sort());
255
+ if (norm(intentOnly ? asStated(a, b) : a) === norm(b))
256
+ return false;
257
+ if (intentOnly && hasPlaceholder(b))
258
+ return false;
259
+ if (partial.after && b.length === 0 && a.length > 0)
260
+ return false;
261
+ if (partial.before && a.length === 0 && b.length > 0)
262
+ return false;
263
+ return true;
264
+ };
265
+ /** The same rule for a single nested object, such as an automation's condition. */
266
+ const nestedObjectDiffers = (a, b, partial, intentOnly = false) => {
267
+ if (canonicalString(a) === canonicalString(b))
268
+ return false;
269
+ if (nestedSame(a, b, intentOnly))
270
+ return false;
271
+ if (intentOnly && hasPlaceholder(b))
272
+ return false;
273
+ if (partial.after && (b === null || b === undefined) && a)
274
+ return false;
275
+ if (partial.before && (a === null || a === undefined) && b)
276
+ return false;
277
+ return true;
278
+ };
279
+ const naturalKeyOf = (entity, row, ctx = {}) => {
280
+ const spec = ENTITIES[entity];
281
+ if (entity === 'fields')
282
+ return `${ctx.phaseName ?? row['phase_id'] ?? ''}::${String(row['label'] ?? '')}`;
283
+ const keys = spec.naturalKey ?? ['name'];
284
+ return keys.map((k) => String(row[k] ?? '')).join('::');
285
+ };
286
+ const matchRows = (before, after, m, mode) => {
287
+ const pairs = [];
288
+ const usedAfter = new Set();
289
+ const remainingBefore = [];
290
+ const afterByPrimary = new Map();
291
+ for (const r of after) {
292
+ const k = m.primary(r);
293
+ if (k)
294
+ afterByPrimary.set(k, r);
295
+ }
296
+ for (const b of before) {
297
+ const k = mode === 'same-repo' ? m.primary(b) : null;
298
+ const hit = k ? afterByPrimary.get(k) : undefined;
299
+ if (hit && !usedAfter.has(hit)) {
300
+ pairs.push([b, hit]);
301
+ usedAfter.add(hit);
302
+ }
303
+ else {
304
+ remainingBefore.push(b);
305
+ }
306
+ }
307
+ // Fallback pass on the natural key. Within one repo this catches an entity
308
+ // whose uuid Claude dropped; across repos it is the primary mechanism.
309
+ const afterByNatural = new Map();
310
+ for (const r of after) {
311
+ if (usedAfter.has(r))
312
+ continue;
313
+ const k = m.natural(r);
314
+ const arr = afterByNatural.get(k) ?? [];
315
+ arr.push(r);
316
+ afterByNatural.set(k, arr);
317
+ }
318
+ const removed = [];
319
+ for (const b of remainingBefore) {
320
+ const candidates = afterByNatural.get(m.natural(b));
321
+ const hit = candidates?.find((c) => !usedAfter.has(c));
322
+ if (hit) {
323
+ pairs.push([b, hit]);
324
+ usedAfter.add(hit);
325
+ }
326
+ else {
327
+ removed.push(b);
328
+ }
329
+ }
330
+ const added = after.filter((r) => !usedAfter.has(r));
331
+ return { pairs, added, removed };
332
+ };
333
+ // ── entity diffs ─────────────────────────────────────────────────────────────
334
+ const num = (v) => {
335
+ const n = Number(v);
336
+ return Number.isFinite(n) ? n : null;
337
+ };
338
+ const label = (row, fallback) => String(row['name'] ?? row['label'] ?? fallback);
339
+ /**
340
+ * The live condition, reduced to what the intent states, before it is summarised.
341
+ *
342
+ * `conditionSummary` canonicalises each expression into a *string* through
343
+ * `setOf`, so by the time the summary exists there are no keys left to reduce —
344
+ * a server-minted `structure_id` is already baked into the string and the two
345
+ * sides can never match. The reduction has to happen first.
346
+ */
347
+ const statedCondition = (b, a, intentOnly) => ({
348
+ condition: (intentOnly
349
+ ? asStated(b.condition ?? null, a.condition ?? null)
350
+ : (b.condition ?? null)),
351
+ });
352
+ const conditionSummary = (node) => {
353
+ const c = node.condition;
354
+ if (!c)
355
+ return {};
356
+ return { expressions_structure: c['expressions_structure'] ?? [], expressions: setOf(c['expressions'] ?? []) };
357
+ };
358
+ export const diffTrees = (before, after, opts = {}) => {
359
+ const mode = opts.mode ?? 'same-repo';
360
+ const intentOnly = opts.intentOnly ?? false;
361
+ const createdIds = new Set((opts.createdIds ?? []).map(Number));
362
+ /** True when two jump sets differ only by ids this apply created. */
363
+ const jumpsDifferOnlyByNewIds = (x, y) => {
364
+ if (!createdIds.size)
365
+ return false;
366
+ const sx = new Set(x.map(Number));
367
+ const sy = new Set(y.map(Number));
368
+ const only = [...sx].filter((v) => !sy.has(v)).concat([...sy].filter((v) => !sx.has(v)));
369
+ return only.length > 0 && only.every((v) => createdIds.has(v));
370
+ };
371
+ const changes = [];
372
+ const skipped = [];
373
+ const idMap = {};
374
+ /**
375
+ * An entity group neither read path could see is not diffed. This is the
376
+ * whole reason coverage is tracked: a reconstructed pull cannot tell "no
377
+ * webhooks" from "cannot read webhooks", and proposing deletions from the
378
+ * second would be catastrophic.
379
+ */
380
+ const skipSet = new Set(opts.skipEntities ?? []);
381
+ /** Which sides read this group only partially. See deltasOf. */
382
+ const partialSides = (entity) => ({
383
+ before: (before.meta.coverage?.[entity] ?? 'unknown') === 'partial',
384
+ after: (after.meta.coverage?.[entity] ?? 'unknown') === 'partial',
385
+ });
386
+ /**
387
+ * Whether a row's absence from one side is evidence of anything.
388
+ *
389
+ * The row-level counterpart of the column rule, and the more dangerous of the
390
+ * two. Measured on a real pipe: the internal automations list returns 13 of the
391
+ * 15 automations a snapshot contains. Diffed naively, the two it omits are
392
+ * "deletes" — and an apply would have deleted two live automations because one
393
+ * read path could not see them.
394
+ *
395
+ * So on a partial side, absence means unseen: a delete is suppressed when the
396
+ * *after* side is partial, and a create when the *before* side is partial.
397
+ */
398
+ const suppressed = [];
399
+ const rowAbsenceIsUnknown = (entity, op, label) => {
400
+ const sides = partialSides(entity);
401
+ const unknown = op === 'delete' ? sides.after : sides.before;
402
+ if (unknown)
403
+ suppressed.push({ entity, op, label });
404
+ return unknown;
405
+ };
406
+ /**
407
+ * The repo row is not an entity group, so its partial-ness comes from the read
408
+ * path: a reconstruction that had to skip pipe columns records that in
409
+ * meta.repo_columns.
410
+ */
411
+ const repoPartial = {
412
+ before: before.meta.repo_columns === 'partial',
413
+ after: after.meta.repo_columns === 'partial',
414
+ };
415
+ const coverageBlocked = (entity) => {
416
+ if (skipSet.has(entity))
417
+ return true;
418
+ const a = before.meta.coverage?.[entity] ?? 'unknown';
419
+ const b = after.meta.coverage?.[entity] ?? 'unknown';
420
+ if (a === 'unknown' || b === 'unknown') {
421
+ skipped.push({
422
+ entity,
423
+ reason: `coverage is ${a === 'unknown' ? 'unknown on the baseline' : 'unknown on the edited tree'} — not diffed, so absence is never read as deletion`,
424
+ });
425
+ return true;
426
+ }
427
+ return false;
428
+ };
429
+ const record = (c) => {
430
+ changes.push({ deltas: [], flags: [], ...c });
431
+ };
432
+ // ── repo row ───────────────────────────────────────────────────────────────
433
+ const repoDeltas = deltasOf(before.repo, after.repo, undefined, repoPartial, intentOnly);
434
+ if (repoDeltas.length) {
435
+ record({
436
+ entity: 'repo',
437
+ op: 'update',
438
+ key: `repo:${after.repo['id']}`,
439
+ label: String(after.repo['name'] ?? 'pipe'),
440
+ file: 'pipe.json',
441
+ before: before.repo,
442
+ after: after.repo,
443
+ deltas: repoDeltas,
444
+ });
445
+ }
446
+ // ── phases (+ fields, jumps, memberships) ──────────────────────────────────
447
+ const phaseMatcher = {
448
+ primary: (p) => (p['uuid'] ? `uuid:${p['uuid']}` : p['id'] ? `id:${p['id']}` : null),
449
+ natural: (p) => naturalKeyOf('phases', p),
450
+ };
451
+ const phaseMatch = matchRows(before.phases, after.phases, phaseMatcher, mode);
452
+ const phaseIdMap = {};
453
+ for (const [b, a] of phaseMatch.pairs) {
454
+ const bid = num(b['id']);
455
+ const aid = num(a['id']);
456
+ if (bid !== null && aid !== null)
457
+ phaseIdMap[String(bid)] = aid;
458
+ }
459
+ idMap.phases = phaseIdMap;
460
+ /**
461
+ * Field changes are collected before they are recorded.
462
+ *
463
+ * Fields are matched inside their phase, so a field moved from one phase to
464
+ * another looks like a delete here and a create there. Compiling that pair
465
+ * literally would delete the field and its data and recreate it empty —
466
+ * precisely the loss PLAN.md §2 says must be refused. So the pair is
467
+ * reconciled afterwards into one `fields.update` carrying a `phase_id` delta,
468
+ * which the registry then refuses by name.
469
+ */
470
+ const fieldChanges = [];
471
+ const pushField = (c) => {
472
+ fieldChanges.push({ deltas: [], flags: [], ...c });
473
+ };
474
+ for (const p of phaseMatch.added) {
475
+ if (rowAbsenceIsUnknown('phases', 'create', label(p, 'phase')))
476
+ continue;
477
+ record({
478
+ entity: 'phases',
479
+ op: 'create',
480
+ key: phaseMatcher.primary(p) ?? `new:${naturalKeyOf('phases', p)}`,
481
+ label: label(p, 'phase'),
482
+ after: p,
483
+ flags: ['new-phase'],
484
+ });
485
+ // A new phase's fields are created with it, as separate steps.
486
+ for (const f of p.fields) {
487
+ pushField({
488
+ entity: 'fields',
489
+ op: 'create',
490
+ key: f['uuid'] ? `uuid:${f['uuid']}` : `new:${naturalKeyOf('fields', f, { phaseName: label(p, '') })}`,
491
+ label: `${label(p, 'phase')} / ${label(f, 'field')}`,
492
+ after: f,
493
+ owner: { entity: 'phases', key: phaseMatcher.primary(p) ?? '', label: label(p, 'phase') },
494
+ });
495
+ }
496
+ }
497
+ for (const p of phaseMatch.removed) {
498
+ if (rowAbsenceIsUnknown('phases', 'delete', label(p, 'phase')))
499
+ continue;
500
+ record({
501
+ entity: 'phases',
502
+ op: 'delete',
503
+ key: phaseMatcher.primary(p) ?? `gone:${naturalKeyOf('phases', p)}`,
504
+ label: label(p, 'phase'),
505
+ before: p,
506
+ flags: Number(p['index']) === 0 ? ['start-form', 'destructive'] : ['destructive'],
507
+ });
508
+ }
509
+ for (const [b, a] of phaseMatch.pairs) {
510
+ const phaseLabel = label(a, 'phase');
511
+ const ownerKey = phaseMatcher.primary(a) ?? '';
512
+ const pDeltas = deltasOf(b, a, new Set(['fields', 'jump_target_ids', 'team_member_ids', 'index']), partialSides('phases'), intentOnly);
513
+ if (pDeltas.length) {
514
+ record({
515
+ entity: 'phases',
516
+ op: 'update',
517
+ key: ownerKey,
518
+ label: phaseLabel,
519
+ before: b,
520
+ after: a,
521
+ deltas: pDeltas,
522
+ // Only the name is refused (compile.ts) — everything else about the
523
+ // start form (description, lateness, and the like) is an ordinary
524
+ // update. Tagged the same way its delete already is, rather than a
525
+ // second mechanism, so both live in one place.
526
+ flags: Number(a['index']) === 0 ? ['start-form'] : [],
527
+ });
528
+ }
529
+ if (num(b['index']) !== num(a['index'])) {
530
+ record({
531
+ entity: 'phases',
532
+ op: 'reorder',
533
+ key: ownerKey,
534
+ label: phaseLabel,
535
+ before: b,
536
+ after: a,
537
+ deltas: [{ key: 'index', before: b['index'], after: a['index'] }],
538
+ flags: ['phase-reorder'],
539
+ });
540
+ }
541
+ /**
542
+ * A newly created phase gets jumps whether it asks for them or not: Pipefy
543
+ * auto-links a new phase with its neighbour in both directions. So an empty
544
+ * stated set on a new phase is an absence of instruction, not an instruction
545
+ * — a create path has no way to say "and nothing else" — and verification
546
+ * must not fail on the platform's default. A *stated* non-empty set is real
547
+ * intent and is written; see compile.ts.
548
+ */
549
+ const newPhaseWithoutStatedJumps = intentOnly && (a['_new'] === true || a['id'] === null) && (a.jump_target_ids ?? []).length === 0;
550
+ if (!coverageBlocked('phase_jumps') &&
551
+ !newPhaseWithoutStatedJumps &&
552
+ !sameIdSet(b.jump_target_ids, a.jump_target_ids) &&
553
+ !(intentOnly && jumpsDifferOnlyByNewIds(b.jump_target_ids, a.jump_target_ids))) {
554
+ record({
555
+ entity: 'phase_jumps',
556
+ op: 'update',
557
+ key: `jumps:${ownerKey}`,
558
+ label: `${phaseLabel} → jumps`,
559
+ // The phase's numeric id travels with the change. The matcher key is
560
+ // "uuid:<uuid>" for a phase, and stripping that prefix yielded a uuid
561
+ // where the settings endpoint wants an id — a silent 404 on write.
562
+ before: { phase_id: b['id'], jump_target_ids: b.jump_target_ids },
563
+ after: { phase_id: a['id'], jump_target_ids: a.jump_target_ids },
564
+ deltas: [{ key: 'jump_target_ids', before: b.jump_target_ids, after: a.jump_target_ids }],
565
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
566
+ flags: ['internal-api'],
567
+ });
568
+ }
569
+ if (canonicalString(b.team_member_ids) !== canonicalString(a.team_member_ids)) {
570
+ record({
571
+ entity: 'phase_team_memberships',
572
+ op: 'update',
573
+ key: `team:${ownerKey}`,
574
+ label: `${phaseLabel} → team`,
575
+ before: { phase_id: b['id'], team_member_ids: b.team_member_ids },
576
+ after: { phase_id: a['id'], team_member_ids: a.team_member_ids },
577
+ deltas: [{ key: 'team_member_ids', before: b.team_member_ids, after: a.team_member_ids }],
578
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
579
+ });
580
+ }
581
+ /**
582
+ * A field authored inside a phase file inherits that phase's id.
583
+ *
584
+ * Fields live nested under their phase precisely so nobody has to keep a
585
+ * `phase_id` in two places, so a hand-authored new field arrives with
586
+ * `"phase_id": null`. Filling it from the owner here is not inventing data —
587
+ * `pack` does exactly the same thing on the way back to the payload — and
588
+ * skipping it sent `createPhaseField` an empty id, which the API answered
589
+ * with "Phase not found with id: ".
590
+ *
591
+ * A field under a phase that is itself new keeps its null: that id does not
592
+ * exist yet, and the compiler substitutes a placeholder for it instead.
593
+ */
594
+ const own = (f) => (f['phase_id'] == null && a['id'] != null ? { ...f, phase_id: a['id'] } : f);
595
+ // fields inside a matched phase
596
+ const fieldMatcher = {
597
+ primary: (f) => (f['uuid'] ? `uuid:${f['uuid']}` : f['id'] ? `id:${f['id']}` : null),
598
+ natural: (f) => naturalKeyOf('fields', f, { phaseName: phaseLabel }),
599
+ };
600
+ const fm = matchRows(b.fields, a.fields, fieldMatcher, mode);
601
+ for (const f of fm.added) {
602
+ if (rowAbsenceIsUnknown('fields', 'create', label(f, 'field')))
603
+ continue;
604
+ pushField({
605
+ entity: 'fields',
606
+ op: 'create',
607
+ key: fieldMatcher.primary(f) ?? `new:${fieldMatcher.natural(f)}`,
608
+ label: `${phaseLabel} / ${label(f, 'field')}`,
609
+ after: own(f),
610
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
611
+ });
612
+ }
613
+ for (const f of fm.removed) {
614
+ if (rowAbsenceIsUnknown('fields', 'delete', label(f, 'field')))
615
+ continue;
616
+ pushField({
617
+ entity: 'fields',
618
+ op: 'delete',
619
+ key: fieldMatcher.primary(f) ?? `gone:${fieldMatcher.natural(f)}`,
620
+ label: `${phaseLabel} / ${label(f, 'field')}`,
621
+ before: f,
622
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
623
+ flags: ['destructive'],
624
+ });
625
+ }
626
+ for (const [fb, fa] of fm.pairs) {
627
+ const fid = num(fb['id']);
628
+ const faid = num(fa['id']);
629
+ if (fid !== null && faid !== null) {
630
+ idMap.fields = { ...(idMap.fields ?? {}), [String(fid)]: faid };
631
+ }
632
+ /**
633
+ * `phase_id` on a field is a consequence of which phase file it sits in, not
634
+ * a value anyone edits — and a genuine cross-phase move is detected by the
635
+ * reconciliation pass below, which states the delta explicitly. Comparing it
636
+ * here only ever reports noise, such as the `null` an author writes for a
637
+ * field inside a phase that does not exist yet.
638
+ */
639
+ const archivedBefore = Boolean(fb['archived_at']);
640
+ const archivedAfter = Boolean(fa['archived_at']);
641
+ /**
642
+ * On a database, `index` is excluded from verification but not from the diff.
643
+ *
644
+ * A database's column order is written by `setTableFieldOrder`, which
645
+ * **renumbers**: asking for slugs in a given order and reading back gives
646
+ * 1, 2, 3, 4 where the workspace wrote 0, 1, 2, 3. The order is right and
647
+ * every absolute index is different, so comparing the column turns a
648
+ * correct reorder into a verification failure — and a failed verification
649
+ * blocks the file sync, leaving the workspace permanently dirty for a
650
+ * difference nobody can fix. The order write asserts its own effect from
651
+ * the `table_fields` it returns, which is the stronger check anyway.
652
+ */
653
+ const fieldIgnore = intentOnly && before.meta.repoKind === 'table'
654
+ ? new Set(['archived_at', 'phase_id', 'index'])
655
+ : new Set(['archived_at', 'phase_id']);
656
+ const deltas = deltasOf(fb, fa, fieldIgnore, partialSides('fields'), intentOnly);
657
+ if (archivedBefore !== archivedAfter) {
658
+ pushField({
659
+ entity: 'fields',
660
+ op: archivedAfter ? 'archive' : 'unarchive',
661
+ key: fieldMatcher.primary(fa) ?? '',
662
+ label: `${phaseLabel} / ${label(fa, 'field')}`,
663
+ before: fb,
664
+ after: fa,
665
+ deltas: [{ key: 'archived_at', before: fb['archived_at'] ?? null, after: fa['archived_at'] ?? null }],
666
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
667
+ });
668
+ }
669
+ if (!deltas.length)
670
+ continue;
671
+ const flags = [];
672
+ if (deltas.some((d) => d.key === 'type_id'))
673
+ flags.push('field-type-change');
674
+ if (deltas.some((d) => d.key === 'phase_id'))
675
+ flags.push('field-phase-move');
676
+ if (deltas.length === 1 && deltas[0].key === 'index')
677
+ flags.push('field-reorder');
678
+ pushField({
679
+ entity: 'fields',
680
+ op: 'update',
681
+ key: fieldMatcher.primary(fa) ?? '',
682
+ label: `${phaseLabel} / ${label(fa, 'field')}`,
683
+ before: fb,
684
+ after: fa,
685
+ deltas,
686
+ owner: { entity: 'phases', key: ownerKey, label: phaseLabel },
687
+ flags,
688
+ });
689
+ }
690
+ }
691
+ /**
692
+ * Reconcile field moves. A delete and a create sharing an exact identity —
693
+ * uuid, or numeric id — is one field that changed phase, never two fields.
694
+ * Matching only on exact identity is deliberate: pairing on label would turn
695
+ * "delete Notes from A, add Notes to B" into a move when it may genuinely be
696
+ * two different fields.
697
+ */
698
+ const identityOf = (c) => {
699
+ const row = c.after ?? c.before;
700
+ if (!row)
701
+ return null;
702
+ if (row['uuid'])
703
+ return `uuid:${row['uuid']}`;
704
+ if (row['id'] !== null && row['id'] !== undefined)
705
+ return `id:${row['id']}`;
706
+ return null;
707
+ };
708
+ const createdById = new Map();
709
+ for (const c of fieldChanges) {
710
+ if (c.op !== 'create')
711
+ continue;
712
+ const id = identityOf(c);
713
+ if (id)
714
+ createdById.set(id, c);
715
+ }
716
+ const consumed = new Set();
717
+ for (const del of fieldChanges) {
718
+ if (del.op !== 'delete')
719
+ continue;
720
+ const id = identityOf(del);
721
+ if (!id)
722
+ continue;
723
+ const created = createdById.get(id);
724
+ if (!created || consumed.has(created))
725
+ continue;
726
+ consumed.add(created);
727
+ consumed.add(del);
728
+ const before = del.before ?? {};
729
+ const after = created.after ?? {};
730
+ const deltas = deltasOf(before, after, undefined, partialSides('fields'), intentOnly);
731
+ if (!deltas.some((d) => d.key === 'phase_id')) {
732
+ deltas.push({ key: 'phase_id', before: before['phase_id'] ?? null, after: after['phase_id'] ?? null });
733
+ }
734
+ record({
735
+ entity: 'fields',
736
+ op: 'update',
737
+ key: id,
738
+ label: `${del.owner?.label ?? '?'} -> ${created.owner?.label ?? '?'} / ${label(after, 'field')}`,
739
+ before,
740
+ after,
741
+ deltas,
742
+ owner: created.owner,
743
+ flags: ['field-phase-move'],
744
+ });
745
+ }
746
+ for (const c of fieldChanges)
747
+ if (!consumed.has(c))
748
+ changes.push(c);
749
+ // ── automations (condition, field_maps, schemas fold in) ───────────────────
750
+ if (!coverageBlocked('automations')) {
751
+ const autoMatcher = {
752
+ primary: (a) => (a['uuid'] ? `uuid:${a['uuid']}` : a['id'] ? `id:${a['id']}` : null),
753
+ natural: (a) => naturalKeyOf('automations', a),
754
+ };
755
+ /**
756
+ * A behavior — an automation whose `action_id` is `ai_behavior` — is
757
+ * excluded here, not diffed as an automation at all. `diff/agents.ts` diffs
758
+ * it instead, because sending one is not `createAutomation`/
759
+ * `updateAutomation`: this pipe's catalogue does not offer `ai_behavior` to
760
+ * either (confirmed live, refused), and the real write path —
761
+ * `createAiAgent`/`updateAiAgent` — replaces an agent's entire behaviors
762
+ * list in one call, which this per-automation diff has no way to express.
763
+ * Diffing it here too would propose a doomed `createAutomation`/
764
+ * `updateAutomation`/`deleteAutomation` step alongside the real one.
765
+ *
766
+ * Matched on `action_id`, not the `_agent_dir` tag: the tag is derived by
767
+ * `readTree` from where a file sits on disk, so it is only ever present on
768
+ * the *edited* side. The baseline side is `unpack()` of a stored envelope —
769
+ * no filesystem, no tag — so filtering on `_agent_dir` alone was one-sided:
770
+ * every existing behavior read as deleted on the very first diff after a
771
+ * fresh pull, because `before` still had it and `after` had been filtered.
772
+ */
773
+ const withoutBehaviors = (rows) => rows.filter((a) => a['action_id'] !== 'ai_behavior');
774
+ const am = matchRows(withoutBehaviors(before.automations), withoutBehaviors(after.automations), autoMatcher, mode);
775
+ for (const a of am.added) {
776
+ if (rowAbsenceIsUnknown('automations', 'create', label(a, 'automation')))
777
+ continue;
778
+ record({
779
+ entity: 'automations',
780
+ op: 'create',
781
+ key: autoMatcher.primary(a) ?? `new:${autoMatcher.natural(a)}`,
782
+ label: label(a, 'automation'),
783
+ after: a,
784
+ });
785
+ }
786
+ for (const a of am.removed) {
787
+ if (rowAbsenceIsUnknown('automations', 'delete', label(a, 'automation')))
788
+ continue;
789
+ record({
790
+ entity: 'automations',
791
+ op: 'delete',
792
+ key: autoMatcher.primary(a) ?? `gone:${autoMatcher.natural(a)}`,
793
+ label: label(a, 'automation'),
794
+ before: a,
795
+ flags: ['destructive'],
796
+ });
797
+ }
798
+ for (const [b, a] of am.pairs) {
799
+ const deltas = deltasOf(b, a, new Set(['condition', 'field_maps', 'distribute_assignments', 'response_schemas']), partialSides('automations'), intentOnly);
800
+ const nested = [];
801
+ const condPartial = partialSides('conditions');
802
+ const autoCond = statedCondition(b, a, intentOnly);
803
+ if (nestedObjectDiffers(conditionSummary(autoCond), conditionSummary(a), condPartial, intentOnly) &&
804
+ nestedObjectDiffers(autoCond.condition, a.condition, condPartial, intentOnly)) {
805
+ nested.push({ key: 'condition', before: b.condition ?? null, after: a.condition ?? null });
806
+ }
807
+ if (nestedDiffers(b.field_maps, a.field_maps, partialSides('field_maps'), intentOnly)) {
808
+ nested.push({ key: 'field_maps', before: b.field_maps, after: a.field_maps });
809
+ }
810
+ if (nestedDiffers(b.distribute_assignments, a.distribute_assignments, partialSides('distribute_assignments'), intentOnly)) {
811
+ nested.push({ key: 'distribute_assignments', before: b.distribute_assignments, after: a.distribute_assignments });
812
+ }
813
+ if (nestedDiffers(b.response_schemas, a.response_schemas, partialSides('response_schemas'), intentOnly)) {
814
+ nested.push({ key: 'response_schemas', before: b.response_schemas, after: a.response_schemas });
815
+ }
816
+ const all = [...deltas, ...nested];
817
+ if (!all.length)
818
+ continue;
819
+ record({
820
+ entity: 'automations',
821
+ op: 'update',
822
+ key: autoMatcher.primary(a) ?? '',
823
+ label: label(a, 'automation'),
824
+ before: b,
825
+ after: a,
826
+ deltas: all,
827
+ flags: nested.length ? ['nested-change'] : [],
828
+ });
829
+ }
830
+ }
831
+ // ── field conditions (actions + condition fold in) ─────────────────────────
832
+ if (!coverageBlocked('field_conditions')) {
833
+ /**
834
+ * Verification matches a condition by name alone.
835
+ *
836
+ * A field condition has no uuid, so its natural key is (phase_id, name) — and
837
+ * phase_id is exactly what a mis-targeted create gets wrong, which means the
838
+ * rows never match and the result reads as an unrelated create plus delete
839
+ * rather than "this landed on the wrong phase". Measured: createFieldCondition
840
+ * accepted phaseId 344123715 and put the condition on the start form, and
841
+ * this diff called it two changes instead of one wrong one.
842
+ */
843
+ const fcMatcher = {
844
+ primary: (f) => (f['id'] ? `id:${f['id']}` : null),
845
+ natural: (f) => (intentOnly ? String(f['name'] ?? '') : naturalKeyOf('field_conditions', f)),
846
+ };
847
+ const fm = matchRows(before.field_conditions, after.field_conditions, fcMatcher, mode);
848
+ for (const f of fm.added) {
849
+ if (rowAbsenceIsUnknown('field_conditions', 'create', label(f, 'condition')))
850
+ continue;
851
+ record({ entity: 'field_conditions', op: 'create', key: fcMatcher.primary(f) ?? `new:${fcMatcher.natural(f)}`, label: label(f, 'condition'), after: f });
852
+ }
853
+ for (const f of fm.removed) {
854
+ if (rowAbsenceIsUnknown('field_conditions', 'delete', label(f, 'condition')))
855
+ continue;
856
+ record({ entity: 'field_conditions', op: 'delete', key: fcMatcher.primary(f) ?? `gone:${fcMatcher.natural(f)}`, label: label(f, 'condition'), before: f, flags: ['destructive'] });
857
+ }
858
+ for (const [b, a] of fm.pairs) {
859
+ /**
860
+ * `index` is excluded from verification, not from the diff.
861
+ *
862
+ * A condition's position is written by `setFieldConditionOrder`, a
863
+ * separate set-replacing step that reports its own success or failure. The
864
+ * server assigns an index on create, so a newly created condition almost
865
+ * never lands on the number the workspace wrote — and failing
866
+ * verification on that one column blocked the file sync entirely, leaving
867
+ * the workspace permanently dirty for a difference nobody could fix.
868
+ */
869
+ const fcIgnore = intentOnly ? ['actions', 'condition', 'index'] : ['actions', 'condition'];
870
+ const deltas = deltasOf(b, a, new Set(fcIgnore), partialSides('field_conditions'), intentOnly);
871
+ const nested = [];
872
+ if (nestedDiffers(b.actions, a.actions, partialSides('field_condition_actions'), intentOnly)) {
873
+ nested.push({ key: 'actions', before: b.actions, after: a.actions });
874
+ }
875
+ const fcCond = statedCondition(b, a, intentOnly);
876
+ if (nestedObjectDiffers(conditionSummary(fcCond), conditionSummary(a), partialSides('conditions'), intentOnly)) {
877
+ nested.push({ key: 'condition', before: b.condition ?? null, after: a.condition ?? null });
878
+ }
879
+ const all = [...deltas, ...nested];
880
+ if (!all.length)
881
+ continue;
882
+ record({ entity: 'field_conditions', op: 'update', key: fcMatcher.primary(a) ?? '', label: label(a, 'condition'), before: b, after: a, deltas: all });
883
+ }
884
+ }
885
+ // ── flat arrays ────────────────────────────────────────────────────────────
886
+ const flat = [
887
+ { entity: 'labels', before: before.labels, after: after.labels },
888
+ { entity: 'webhooks', before: before.webhooks, after: after.webhooks },
889
+ { entity: 'pipe_relations', before: before.pipe_relations, after: after.pipe_relations },
890
+ ];
891
+ for (const group of flat) {
892
+ if (coverageBlocked(group.entity))
893
+ continue;
894
+ const spec = ENTITIES[group.entity];
895
+ const matcher = {
896
+ primary: (r) => (spec.identity === 'uuid' && r['uuid'] ? `uuid:${r['uuid']}` : r['id'] ? `id:${r['id']}` : null),
897
+ natural: (r) => naturalKeyOf(group.entity, r),
898
+ };
899
+ const m = matchRows(group.before, group.after, matcher, mode);
900
+ for (const r of m.added)
901
+ if (!rowAbsenceIsUnknown(group.entity, 'create', label(r, group.entity)))
902
+ record({ entity: group.entity, op: 'create', key: matcher.primary(r) ?? `new:${matcher.natural(r)}`, label: label(r, group.entity), after: r });
903
+ for (const r of m.removed)
904
+ if (!rowAbsenceIsUnknown(group.entity, 'delete', label(r, group.entity)))
905
+ record({ entity: group.entity, op: 'delete', key: matcher.primary(r) ?? `gone:${matcher.natural(r)}`, label: label(r, group.entity), before: r, flags: ['destructive'] });
906
+ for (const [b, a] of m.pairs) {
907
+ const deltas = deltasOf(b, a, UNREADABLE_IN_PAYLOAD[group.entity], partialSides(group.entity), intentOnly);
908
+ if (deltas.length)
909
+ record({ entity: group.entity, op: 'update', key: matcher.primary(a) ?? '', label: label(a, group.entity), before: b, after: a, deltas });
910
+ }
911
+ }
912
+ // ── singletons ─────────────────────────────────────────────────────────────
913
+ const singleton = (entity, b, a, file) => {
914
+ if (coverageBlocked(entity))
915
+ return;
916
+ if (!b && !a)
917
+ return;
918
+ if (b && !a) {
919
+ record({ entity, op: 'delete', key: entity, label: entity, before: b, file, flags: ['destructive'] });
920
+ return;
921
+ }
922
+ if (!b && a) {
923
+ record({ entity, op: 'create', key: entity, label: entity, after: a, file });
924
+ return;
925
+ }
926
+ const deltas = deltasOf(b, a, undefined, partialSides(entity), intentOnly);
927
+ if (deltas.length)
928
+ record({ entity, op: 'update', key: entity, label: entity, before: b, after: a, deltas, file });
929
+ };
930
+ singleton('public_forms', before.public_form, after.public_form, 'public-form.json');
931
+ singleton('repo_preferences', before.preferences, after.preferences, 'preferences.json');
932
+ // ── read-only groups: any change here is a mistake, reported as such ───────
933
+ const ro = [
934
+ ['email_templates', before.readonly.email_templates, after.readonly.email_templates, '_readonly/email-templates.json'],
935
+ ['email_inboxes', before.readonly.email_inboxes, after.readonly.email_inboxes, '_readonly/email-inboxes.json'],
936
+ ['visibilities', before.readonly.visibilities, after.readonly.visibilities, '_readonly/visibilities.json'],
937
+ ['field_maps', before.readonly.field_maps, after.readonly.field_maps, '_readonly/field-maps.json'],
938
+ ];
939
+ for (const [entity, b, a, file] of ro) {
940
+ if (sameSet(b, a) && b.length === a.length)
941
+ continue;
942
+ /**
943
+ * A read path that cannot see a read-only group is not an edit to it. The
944
+ * reconstruction has no query for visibilities, so without this every
945
+ * reconstructed tree "edited" them to empty.
946
+ */
947
+ if (coverageBlocked(entity) || nestedDiffers(b, a, partialSides(entity)) === false)
948
+ continue;
949
+ record({
950
+ entity,
951
+ op: 'update',
952
+ key: entity,
953
+ label: `${entity} (read-only)`,
954
+ before: { rows: b },
955
+ after: { rows: a },
956
+ deltas: [{ key: 'rows', before: b, after: a }],
957
+ file,
958
+ flags: ['readonly-edit'],
959
+ });
960
+ }
961
+ for (const s of suppressed) {
962
+ skipped.push({
963
+ entity: s.entity,
964
+ reason: `"${s.label}" would read as a ${s.op}, but that side's coverage is partial — ` +
965
+ `absence there is unseen, not gone. Re-read from a snapshot to decide.`,
966
+ });
967
+ }
968
+ return {
969
+ mode,
970
+ from: { repoId: Number(before.repo['id']), name: String(before.repo['name'] ?? ''), source: before.meta.source },
971
+ to: { repoId: Number(after.repo['id']), name: String(after.repo['name'] ?? ''), source: after.meta.source },
972
+ changes,
973
+ skipped,
974
+ idMap,
975
+ };
976
+ };
977
+ export const isEmpty = (cs) => cs.changes.length === 0;
978
+ export const countByOp = (cs) => {
979
+ const out = { create: 0, update: 0, delete: 0, reorder: 0, archive: 0, unarchive: 0 };
980
+ for (const c of cs.changes)
981
+ out[c.op]++;
982
+ return out;
983
+ };