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