@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,722 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ENDPOINTS } from "../config.js";
3
+ import { debug } from "../util/log.js";
4
+ import { checkAutomationParams } from "../pipefy/automations.js";
5
+ import { support } from "./registry.js";
6
+ import { placeholderFor } from "./idmap.js";
7
+ import * as M from "./mutations.js";
8
+ const opFor = (c) => {
9
+ if (c.entity === 'fields') {
10
+ if (c.flags.includes('field-type-change'))
11
+ return 'change_type';
12
+ if (c.flags.includes('field-phase-move'))
13
+ return 'move_phase';
14
+ if (c.op === 'archive' || c.op === 'unarchive')
15
+ return 'archive';
16
+ if (c.flags.includes('field-reorder'))
17
+ return 'reorder';
18
+ }
19
+ if (c.op === 'reorder')
20
+ return 'reorder';
21
+ if (c.op === 'archive' || c.op === 'unarchive')
22
+ return 'archive';
23
+ return c.op;
24
+ };
25
+ const classify = (c, kind = 'pipe') => {
26
+ const op = opFor(c);
27
+ // A path-based refusal, checked before the registry: an edit under _readonly/
28
+ // is rejected on the basis of the path alone (PLAN.md §4).
29
+ if (c.flags.includes('readonly-edit')) {
30
+ return {
31
+ changeKey: c.key,
32
+ entity: c.entity,
33
+ op,
34
+ status: 'UNSUPPORTED',
35
+ reason: `${c.entity} lives under _readonly/ — it has no public write path and must not be edited`,
36
+ label: c.label,
37
+ };
38
+ }
39
+ if (c.flags.includes('start-form') && c.op === 'delete') {
40
+ return {
41
+ changeKey: c.key,
42
+ entity: c.entity,
43
+ op,
44
+ status: 'UNSUPPORTED',
45
+ reason: 'the start form is a real phase at index 0; deleting it would destroy the pipe entrance',
46
+ label: c.label,
47
+ };
48
+ }
49
+ /**
50
+ * Same flag, one more field: the start form's name is widely referenced by
51
+ * name rather than by id or index — the public form, automations, a
52
+ * client's own documentation — none of which this tool can see to check
53
+ * for breakage. Renaming it is not destructive the way deleting it is, so
54
+ * it is refused rather than reported UNSUPPORTED for a missing write path
55
+ * (`updatePhase` happily accepts `name`); every *other* field on the start
56
+ * form still updates normally.
57
+ */
58
+ if (c.flags.includes('start-form') && c.op === 'update' && c.deltas.some((d) => d.key === 'name')) {
59
+ return {
60
+ changeKey: c.key,
61
+ entity: c.entity,
62
+ op,
63
+ status: 'UNSUPPORTED',
64
+ reason: 'the start form’s name is refused on purpose — it is widely referenced by name (the public form, automations, a client’s own docs) in places this tool cannot check for breakage',
65
+ label: c.label,
66
+ };
67
+ }
68
+ const s = support(c.entity, op, kind);
69
+ /**
70
+ * Column-level refusal. A path can exist for the entity and still have no
71
+ * input for the column being changed, and a mutation that succeeds while
72
+ * ignoring the change is the one outcome this tool must never produce.
73
+ */
74
+ if (s.settableColumns && c.deltas.length) {
75
+ const settable = new Set(s.settableColumns);
76
+ const unsettable = c.deltas.map((d) => d.key).filter((k) => !settable.has(k));
77
+ if (unsettable.length) {
78
+ return {
79
+ changeKey: c.key,
80
+ entity: c.entity,
81
+ op,
82
+ status: 'UNSUPPORTED',
83
+ reason: `${unsettable.join(', ')} ${unsettable.length === 1 ? 'has' : 'have'} no input on this endpoint. ` +
84
+ `${s.via ?? 'the write path'} accepts only: ${s.settableColumns.join(', ')}`,
85
+ ask: s.ask,
86
+ label: c.label,
87
+ };
88
+ }
89
+ }
90
+ return { changeKey: c.key, entity: c.entity, op, status: s.status, via: s.via, reason: s.reason, ask: s.ask, label: c.label };
91
+ };
92
+ export const compile = async (cs, ctx, options) => {
93
+ const repoKind = ctx.repoKind ?? 'pipe';
94
+ const classifications = cs.changes.map((c) => classify(c, repoKind));
95
+ const blocked = classifications.filter((c) => c.status === 'UNSUPPORTED');
96
+ const warnings = [];
97
+ const steps = [];
98
+ const emit = (partial) => {
99
+ steps.push({ id: `s${String(steps.length + 1).padStart(3, '0')}`, index: steps.length, status: 'pending', ...partial });
100
+ };
101
+ const supported = (key) => classifications.find((c) => c.changeKey === key)?.status !== 'UNSUPPORTED';
102
+ const byEntityOp = (entity, ops) => cs.changes.filter((c) => c.entity === entity && ops.includes(c.op) && supported(c.key));
103
+ const missing = (mutation) => Boolean(ctx.capabilities && ctx.capabilities.mutations[mutation] === false);
104
+ const offline = ctx.client === null;
105
+ if (offline)
106
+ warnings.push('planned offline: request shapes were not checked against the schema, so every step is marked unverified. pipe apply re-compiles with a connection.');
107
+ const livePhaseIdSet = new Set((ctx.livePhaseIds ?? []).map(Number));
108
+ const placeholdersIn = (value) => {
109
+ const found = new Set();
110
+ const walk = (v) => {
111
+ if (typeof v === 'string') {
112
+ for (const m of v.matchAll(/%\{(_new:[^}]+)\}/g))
113
+ found.add(m[1]);
114
+ if (v.startsWith('_new:'))
115
+ found.add(v);
116
+ return;
117
+ }
118
+ if (Array.isArray(v))
119
+ return void v.forEach(walk);
120
+ if (v && typeof v === 'object')
121
+ Object.values(v).forEach(walk);
122
+ };
123
+ walk(value);
124
+ return [...found];
125
+ };
126
+ const graphqlStep = async (c, op, request, extra = {}) => {
127
+ const req = await request;
128
+ if (req.mutation && missing(req.mutation)) {
129
+ warnings.push(`${req.mutation} is not present on this endpoint — "${c.label}" cannot be applied`);
130
+ return;
131
+ }
132
+ if (req.missingRequired?.length) {
133
+ warnings.push(`${req.mutation} requires ${req.missingRequired.join(', ')}, which "${c.label}" does not supply — ` +
134
+ 'no step was emitted, because the request would be rejected');
135
+ return;
136
+ }
137
+ emit({
138
+ changeKey: c.key,
139
+ entity: c.entity,
140
+ op,
141
+ kind: 'graphql',
142
+ description: `${req.mutation ?? 'graphql'} — ${c.label}`,
143
+ request: req,
144
+ /**
145
+ * Derived rather than empty. A step whose request carries a placeholder
146
+ * depends on the step that mints it, and `setFieldConditionOrder` names a
147
+ * condition created in the same plan.
148
+ */
149
+ needs: placeholdersIn(req.variables),
150
+ ...(offline ? { unverified: true } : {}),
151
+ ...extra,
152
+ });
153
+ };
154
+ // ── 1. repo root ───────────────────────────────────────────────────────────
155
+ for (const c of byEntityOp('repo', ['update'])) {
156
+ /**
157
+ * A database's root row goes through `updateTable`, a different mutation with
158
+ * a different settable set. Routed through `updatePipe` instead, every
159
+ * table-only column would be dropped by the argument filter and the mutation
160
+ * would report success having changed nothing.
161
+ */
162
+ await graphqlStep(c, 'update', repoKind === 'table'
163
+ ? M.updateTable(ctx.client, c.after ?? {}, ctx.fieldSlugById ?? {})
164
+ : M.updatePipe(ctx.client, c.after ?? {}, ctx.fieldUuidById ?? {}));
165
+ }
166
+ // ── 2. phases: create, then update ─────────────────────────────────────────
167
+ for (const c of byEntityOp('phases', ['create'])) {
168
+ const req = await M.createPhase(ctx.client, ctx.repoId, c.after ?? {});
169
+ const placeholder = placeholderFor('phases', c.key);
170
+ emit({
171
+ changeKey: c.key,
172
+ entity: 'phases',
173
+ op: 'create',
174
+ kind: 'graphql',
175
+ unverified: offline,
176
+ description: `createPhase — ${c.label}`,
177
+ request: req,
178
+ needs: [],
179
+ produces: {
180
+ placeholder,
181
+ path: ['createPhase', 'phase', 'id'],
182
+ aliases: [`_new:${String(c.after?.['uuid'] ?? '')}`, `_new:${c.label}`].filter((a) => a !== '_new:'),
183
+ },
184
+ });
185
+ }
186
+ for (const c of byEntityOp('phases', ['update'])) {
187
+ await graphqlStep(c, 'update', M.updatePhase(ctx.client, c.after ?? {}));
188
+ }
189
+ // ── 3. fields: create (start form first), then update / reorder / archive ──
190
+ const fieldCreates = byEntityOp('fields', ['create']);
191
+ // The start form is index 0, and the recipes handle it before phase fields
192
+ // (steps 30 then 45) because it is where connector and title fields live.
193
+ const startFormFirst = [...fieldCreates].sort((a, b) => {
194
+ const ai = Number((a.owner?.label ?? '').toLowerCase().includes('start form') ? 0 : 1);
195
+ const bi = Number((b.owner?.label ?? '').toLowerCase().includes('start form') ? 0 : 1);
196
+ return ai - bi;
197
+ });
198
+ for (const c of startFormFirst) {
199
+ const field = c.after ?? {};
200
+ // The phase may itself be new, in which case its id arrives as a placeholder.
201
+ const phaseChange = cs.changes.find((x) => x.entity === 'phases' && x.op === 'create' && x.key === c.owner?.key);
202
+ const phaseId = phaseChange ? placeholderFor('phases', phaseChange.key) : String(field['phase_id'] ?? '');
203
+ /**
204
+ * No phase, no step. `createPhaseField` with an empty `phase_id` is answered
205
+ * with "Phase not found with id: " — an error that names neither the field
206
+ * nor the phase, in the middle of an apply that has already created things.
207
+ * Refusing here says which field, before anything is sent.
208
+ */
209
+ if (!phaseId) {
210
+ warnings.push(`${c.label} has no phase to be created in: its row carries no phase_id and no phase in this plan owns it. ` +
211
+ 'No step was emitted.');
212
+ continue;
213
+ }
214
+ /**
215
+ * A database's column is created by `createTableField`, which takes the
216
+ * database rather than a phase — a database has one form and no other place
217
+ * for a column to live.
218
+ */
219
+ const isTable = repoKind === 'table';
220
+ const req = isTable
221
+ ? await M.createTableField(ctx.client, ctx.repoId, field)
222
+ : await M.createPhaseField(ctx.client, phaseId, field);
223
+ const placeholder = placeholderFor('fields', c.key);
224
+ emit({
225
+ changeKey: c.key,
226
+ entity: 'fields',
227
+ op: 'create',
228
+ kind: 'graphql',
229
+ unverified: offline,
230
+ description: `${isTable ? 'createTableField' : 'createPhaseField'} — ${c.label}`,
231
+ request: req,
232
+ needs: phaseChange && !isTable ? [placeholderFor('phases', phaseChange.key)] : [],
233
+ produces: {
234
+ placeholder,
235
+ path: isTable
236
+ ? ['createTableField', 'table_field', 'internal_id']
237
+ : ['createPhaseField', 'phase_field', 'internal_id'],
238
+ aliases: [`_new:${String(field['uuid'] ?? '')}`, `_new:${String(field['label'] ?? '')}`].filter((a) => a !== '_new:'),
239
+ },
240
+ });
241
+ }
242
+ for (const c of byEntityOp('fields', ['update'])) {
243
+ /**
244
+ * A database's column order is not a column.
245
+ *
246
+ * `UpdateTableFieldInput` has no `index`, so routing a reorder through
247
+ * `updateTableField` would send a request with nothing to do and get a
248
+ * success back — the one outcome this tool must never produce. The order is a
249
+ * separate set-replacing call, emitted once below, so a reorder-only change
250
+ * contributes no per-field step at all.
251
+ */
252
+ if (repoKind === 'table' && c.flags.includes('field-reorder') && !c.deltas.some((d) => d.key !== 'index')) {
253
+ continue;
254
+ }
255
+ /**
256
+ * `updateTableField` addresses a column by its slug and needs the database id
257
+ * alongside it — measured, and the opposite of `updatePhaseField`, whose `id`
258
+ * is the numeric internal id.
259
+ */
260
+ await graphqlStep(c, c.flags.includes('field-reorder') ? 'reorder' : 'update', repoKind === 'table'
261
+ ? M.updateTableField(ctx.client, ctx.repoId, c.after ?? {})
262
+ : M.updatePhaseField(ctx.client, c.after ?? {}));
263
+ }
264
+ /**
265
+ * The database's column order, as one set-replacing write, placed after the
266
+ * creates so a new column can be positioned in the same plan.
267
+ *
268
+ * Emitted only when the order actually differs from the order the database will
269
+ * already have: the baseline order, minus anything this plan deletes, plus this
270
+ * plan's creates in the order they are applied — since `createTableField` has
271
+ * no `index` and a new column lands at the end. So appending a column, the
272
+ * ordinary case, needs no reorder at all.
273
+ *
274
+ * A no-op `setTableFieldOrder` is accepted by the API rather than answered with
275
+ * "Something went wrong" the way `setFieldConditionOrder` is, so this guard is
276
+ * an optimisation rather than a correctness requirement. It is still worth
277
+ * having: a step that does nothing is a step someone has to read.
278
+ */
279
+ if (repoKind === 'table' && ctx.tableFieldOrder?.length) {
280
+ const stated = ctx.tableFieldOrder.map((f) => f['slug'] ? String(f['slug']) : placeholderFor('fields', `new:${String(f['uuid'] ?? f['label'] ?? '')}`));
281
+ const deleted = new Set(cs.changes
282
+ .filter((c) => c.entity === 'fields' && c.op === 'delete')
283
+ .map((c) => String(c.before?.['slug'] ?? '')));
284
+ const appended = cs.changes
285
+ .filter((c) => c.entity === 'fields' && c.op === 'create' && supported(c.key))
286
+ .map((c) => placeholderFor('fields', `new:${String(c.after?.['uuid'] ?? c.after?.['label'] ?? '')}`));
287
+ const natural = [...(ctx.baselineTableFieldOrder ?? []).filter((s) => !deleted.has(s)), ...appended];
288
+ const unchanged = natural.length === stated.length && stated.every((s, i) => s === natural[i]);
289
+ if (!unchanged) {
290
+ const anchor = cs.changes.find((c) => c.entity === 'fields');
291
+ if (anchor) {
292
+ await graphqlStep({ ...anchor, label: `column order in ${ctx.repoId}` }, 'reorder', M.setTableFieldOrder(ctx.client, ctx.repoId, ctx.tableFieldOrder));
293
+ }
294
+ }
295
+ }
296
+ for (const c of byEntityOp('fields', ['archive', 'unarchive'])) {
297
+ await graphqlStep(c, 'archive', M.archiveField(ctx.client, c.after ?? {}, c.op === 'archive'));
298
+ }
299
+ // ── 4. labels, webhooks, relations ─────────────────────────────────────────
300
+ /**
301
+ * `produces` on all three creates below, even though nothing in the same
302
+ * plan ever references a label/webhook/relation by placeholder — that was
303
+ * the only reason it existed before. `apply.ts`'s post-apply id adoption
304
+ * (adopt.ts) reuses the same IdMap for a second job: writing the real id
305
+ * into the file the entity was authored in, with no read-back. Without a
306
+ * `produces` entry the create still succeeds, but the id it returned is
307
+ * never captured anywhere, and the file keeps reading `"id": null, "_new":
308
+ * true"` until the next live read adopts it some other way.
309
+ */
310
+ for (const c of byEntityOp('labels', ['create'])) {
311
+ await graphqlStep(c, 'create', M.createLabel(ctx.client, ctx.repoId, c.after ?? {}, repoKind), {
312
+ produces: { placeholder: placeholderFor('labels', c.key), path: ['createLabel', 'label', 'id'] },
313
+ });
314
+ }
315
+ for (const c of byEntityOp('labels', ['update'])) {
316
+ await graphqlStep(c, 'update', M.updateLabel(ctx.client, c.after ?? {}));
317
+ }
318
+ for (const c of byEntityOp('webhooks', ['create'])) {
319
+ await graphqlStep(c, 'create', M.createWebhook(ctx.client, ctx.repoId, c.after ?? {}), {
320
+ produces: { placeholder: placeholderFor('webhooks', c.key), path: ['createWebhook', 'webhook', 'id'] },
321
+ });
322
+ }
323
+ for (const c of byEntityOp('webhooks', ['update'])) {
324
+ await graphqlStep(c, 'update', M.updateWebhook(ctx.client, c.after ?? {}));
325
+ }
326
+ for (const c of byEntityOp('pipe_relations', ['create'])) {
327
+ await graphqlStep(c, 'create', M.createPipeRelation(ctx.client, c.after ?? {}), {
328
+ produces: { placeholder: placeholderFor('pipe_relations', c.key), path: ['createPipeRelation', 'pipeRelation', 'id'] },
329
+ });
330
+ }
331
+ for (const c of byEntityOp('pipe_relations', ['update'])) {
332
+ await graphqlStep(c, 'update', M.updatePipeRelation(ctx.client, c.after ?? {}));
333
+ }
334
+ // ── 5. field conditions (need field ids) ───────────────────────────────────
335
+ /**
336
+ * Which placeholders a request still names, so the plan can say so before it
337
+ * runs. A field condition is the case that needs it: unlike a field, it lives
338
+ * in its own file and must name its phase, so a condition on a phase created
339
+ * in the same edit reaches here as `"phase_id": "%{_new:<uuid>}"`.
340
+ */
341
+ for (const c of byEntityOp('field_conditions', ['create'])) {
342
+ /**
343
+ * Refused rather than created somewhere else. The mutation ignores the
344
+ * phase it is given, so applying this would put the condition on the start
345
+ * form and verification would then report a phase_id difference nothing can
346
+ * fix — the shape that leaves a workspace permanently dirty.
347
+ */
348
+ const wanted = String(c.after?.['phase_id'] ?? '');
349
+ const startForm = ctx.startFormPhaseId === null || ctx.startFormPhaseId === undefined
350
+ ? ''
351
+ : String(ctx.startFormPhaseId);
352
+ if (startForm && wanted && wanted !== startForm) {
353
+ blocked.push({
354
+ changeKey: c.key,
355
+ entity: 'field_conditions',
356
+ op: 'create',
357
+ status: 'UNSUPPORTED',
358
+ reason: `createFieldCondition ignores phaseId and always creates on the start form (${startForm}), ` +
359
+ `so a condition on phase ${wanted} cannot be created through the API — build it in the UI`,
360
+ label: c.label,
361
+ });
362
+ continue;
363
+ }
364
+ const req = await M.createFieldCondition(ctx.client, c.after ?? {});
365
+ emit({
366
+ changeKey: c.key,
367
+ entity: 'field_conditions',
368
+ op: 'create',
369
+ kind: 'graphql',
370
+ unverified: offline,
371
+ description: `createFieldCondition — ${c.label}`,
372
+ request: req,
373
+ needs: placeholdersIn(req.variables),
374
+ produces: { placeholder: placeholderFor('field_conditions', c.key), path: ['createFieldCondition', 'fieldCondition', 'id'] },
375
+ });
376
+ }
377
+ for (const c of byEntityOp('field_conditions', ['update'])) {
378
+ await graphqlStep(c, 'update', M.updateFieldCondition(ctx.client, c.after ?? {}));
379
+ }
380
+ // ── 6. automations (need field and phase ids) ──────────────────────────────
381
+ /**
382
+ * Every automation is checked against the catalogue before a step is emitted.
383
+ *
384
+ * A parameter the event or action does not accept is rejected by the API with
385
+ * the words "is invalid" and nothing else — no parameter name, no event name.
386
+ * The catalogue says exactly what each accepts, so the refusal can name it.
387
+ *
388
+ * Real case: `event_id: card_moved` with `event_params.inPhaseId`. `inPhaseId`
389
+ * is a real parameter — of `card_inbox_received_email`. `card_moved` accepts
390
+ * only `to_phase_id`.
391
+ */
392
+ const checkAuto = (c) => {
393
+ if (!ctx.automationCatalogue)
394
+ return true;
395
+ const findings = checkAutomationParams(c.after ?? {}, ctx.automationCatalogue);
396
+ const errors = findings.filter((f) => f.severity === 'error');
397
+ if (!errors.length)
398
+ return true;
399
+ for (const f of errors) {
400
+ warnings.push(`${c.label}: ${f.message}${f.fix ? ` — ${f.fix}` : ''}`);
401
+ }
402
+ blocked.push({
403
+ changeKey: c.key,
404
+ entity: c.entity,
405
+ // The change's own op — an update refused by the catalogue is still an
406
+ // update. This was hardcoded to 'create' regardless of which loop called
407
+ // it, so a blocked update reported "automations.create", naming the
408
+ // wrong operation for the one thing an FDE most needs right: whether
409
+ // reverting means undoing an edit or deleting something new.
410
+ // checkAuto is only ever called from the automations create/update
411
+ // loops below, so c.op is always one of these two in practice.
412
+ op: c.op,
413
+ status: 'UNSUPPORTED',
414
+ reason: errors.map((f) => f.message).join('; '),
415
+ label: c.label,
416
+ });
417
+ return false;
418
+ };
419
+ for (const c of byEntityOp('automations', ['create'])) {
420
+ if (!checkAuto(c))
421
+ continue;
422
+ emit({
423
+ changeKey: c.key,
424
+ entity: 'automations',
425
+ op: 'create',
426
+ kind: 'graphql',
427
+ unverified: offline,
428
+ description: `createAutomation — ${c.label}`,
429
+ request: await M.createAutomation(ctx.client, c.after ?? {}),
430
+ needs: placeholdersIn(M.automationVariables(c.after ?? {})),
431
+ produces: { placeholder: placeholderFor('automations', c.key), path: ['createAutomation', 'automation', 'id'] },
432
+ });
433
+ }
434
+ for (const c of byEntityOp('automations', ['update'])) {
435
+ if (!checkAuto(c))
436
+ continue;
437
+ if (options.automationStrategy === 'recreate') {
438
+ /**
439
+ * Delete and recreate, only when asked for.
440
+ *
441
+ * This was the default, inherited from the Change Migrator recipes on the
442
+ * grounds that `action_params` does not round-trip through the update
443
+ * path. Measured against the live API and it does: name, field map value,
444
+ * switching the mapped field, several field maps at once, `input_mode`,
445
+ * `action_id`, `event_id` and removing a condition all round-trip through
446
+ * `updateAutomation`. `UpdateAutomationInput` also accepts every field
447
+ * `CreateAutomationInput` does, so nothing forces a recreate.
448
+ *
449
+ * Recreating costs the automation's id, its uuid and its run history, and
450
+ * leaves a window where the automation does not exist. It stays available
451
+ * for the case where an update is refused and starting clean is the
452
+ * intent.
453
+ */
454
+ emit({
455
+ changeKey: c.key,
456
+ entity: 'automations',
457
+ op: 'delete',
458
+ kind: 'graphql',
459
+ description: `deleteAutomation (recreate strategy) — ${c.label}`,
460
+ request: await M.deleteAutomation(ctx.client, String(c.before?.['id'] ?? '')),
461
+ needs: [],
462
+ destructive: true,
463
+ });
464
+ emit({
465
+ changeKey: c.key,
466
+ entity: 'automations',
467
+ op: 'create',
468
+ kind: 'graphql',
469
+ description: `createAutomation (recreate strategy) — ${c.label}`,
470
+ request: await M.createAutomation(ctx.client, c.after ?? {}),
471
+ needs: placeholdersIn(M.automationVariables(c.after ?? {})),
472
+ produces: { placeholder: placeholderFor('automations', c.key), path: ['createAutomation', 'automation', 'id'] },
473
+ });
474
+ }
475
+ else {
476
+ emit({
477
+ changeKey: c.key,
478
+ entity: 'automations',
479
+ op: 'update',
480
+ kind: 'graphql',
481
+ description: `updateAutomation — ${c.label}`,
482
+ request: await M.updateAutomation(ctx.client, c.after ?? {}),
483
+ needs: [],
484
+ });
485
+ }
486
+ }
487
+ // ── 7. phase jumps and team settings (need phase ids) ──────────────────────
488
+ for (const c of byEntityOp('phase_jumps', ['update'])) {
489
+ const phaseChange = cs.changes.find((x) => x.entity === 'phases' && x.op === 'create' && x.key === c.owner?.key);
490
+ const phaseId = phaseChange
491
+ ? placeholderFor('phases', phaseChange.key)
492
+ : String(c.after?.['phase_id'] ?? c.before?.['phase_id'] ?? '');
493
+ const requested = c.after?.['jump_target_ids'] ?? [];
494
+ /**
495
+ * The endpoint is set-replacing, so a jump set is sent whole. A real pipe
496
+ * carries five jumps pointing at phases that no longer exist — real data
497
+ * from a real pipe. Those orphans are pre-existing and harmless while
498
+ * nothing touches the phase, but resending them as part of an unrelated
499
+ * change would either fail or recreate garbage, so they are dropped here and
500
+ * the drop is reported rather than silent.
501
+ */
502
+ /**
503
+ * A target naming a phase created in this same plan is kept: it has no id to
504
+ * check against yet, and the executor substitutes one before the request
505
+ * goes out. Without this the orphan filter below dropped it as "not a phase
506
+ * of this pipe" — which was true, and beside the point.
507
+ */
508
+ const isPlaceholder = (t) => typeof t === 'string' && t.includes('_new:');
509
+ // A null target is a real state — a jump slot enabled with no destination
510
+ // phase chosen — not an orphan pointing at a deleted phase. Kept out of
511
+ // the orphan check (`Number(null)` is 0, which would otherwise coincide
512
+ // with no real phase id but is still the wrong reason) and left out of
513
+ // the outgoing set on its own: there is no confirmed way to tell this
514
+ // set-replacing endpoint "and one slot with no target", so guessing one
515
+ // (sending the literal string "null") risks writing garbage to a live
516
+ // pipe rather than just dropping a slot nothing here can express yet.
517
+ const targets = requested.filter((t) => t !== null && (isPlaceholder(t) || livePhaseIdSet.size === 0 || livePhaseIdSet.has(Number(t))));
518
+ const droppedOrphans = requested.filter((t) => t !== null && !targets.includes(t));
519
+ const droppedNullCount = requested.filter((t) => t === null).length;
520
+ if (droppedOrphans.length) {
521
+ warnings.push(`phase "${c.owner?.label ?? phaseId}": jump target(s) ${droppedOrphans.join(', ')} are not phases of this pipe and were left out of the set`);
522
+ }
523
+ if (droppedNullCount) {
524
+ warnings.push(`phase "${c.owner?.label ?? phaseId}": ${droppedNullCount} jump target(s) with no destination phase chosen were left out of the set — there is no confirmed way to send "no target" to this endpoint`);
525
+ }
526
+ emit({
527
+ changeKey: c.key,
528
+ entity: 'phase_jumps',
529
+ op: 'update',
530
+ kind: 'internal-rest',
531
+ description: `PUT settings/phases/:id — ${c.label} → [${targets.join(', ')}]`,
532
+ request: {
533
+ method: 'PUT',
534
+ path: `${ENDPOINTS.internalSettings}/phases/${phaseId}`,
535
+ form: { 'phase[jump_target_ids][]': targets.map(String) },
536
+ },
537
+ needs: [
538
+ ...(phaseChange ? [placeholderFor('phases', phaseChange.key)] : []),
539
+ ...placeholdersIn(targets),
540
+ ],
541
+ unverified: true,
542
+ });
543
+ }
544
+ /**
545
+ * A created phase's stated jump set has no diff of its own — there is no
546
+ * baseline row to compare against — so it would never be written, and Pipefy's
547
+ * auto-link would stand instead. Emitted only when the author stated targets:
548
+ * an empty set is silence, and forcing it would leave the phase unreachable.
549
+ */
550
+ for (const c of byEntityOp('phases', ['create'])) {
551
+ const stated = c.after?.['jump_target_ids'] ?? [];
552
+ const targets = stated.filter((t) => t !== null);
553
+ const droppedNullCount = stated.length - targets.length;
554
+ if (droppedNullCount) {
555
+ warnings.push(`phase "${c.label}": ${droppedNullCount} jump target(s) with no destination phase chosen were left out of the set — there is no confirmed way to send "no target" to this endpoint`);
556
+ }
557
+ if (!targets.length)
558
+ continue;
559
+ const phasePlaceholder = placeholderFor('phases', c.key);
560
+ const resolved = targets.map((t) => String(t));
561
+ emit({
562
+ changeKey: c.key,
563
+ entity: 'phase_jumps',
564
+ op: 'update',
565
+ kind: 'internal-rest',
566
+ description: `PUT settings/phases/:id — ${c.label} → [${resolved.join(', ')}] (new phase)`,
567
+ request: {
568
+ method: 'PUT',
569
+ path: `${ENDPOINTS.internalSettings}/phases/${phasePlaceholder}`,
570
+ form: { 'phase[jump_target_ids][]': resolved },
571
+ },
572
+ needs: [phasePlaceholder],
573
+ unverified: true,
574
+ });
575
+ }
576
+ for (const c of byEntityOp('phase_team_memberships', ['update'])) {
577
+ const phaseId = String(c.after?.['phase_id'] ?? c.before?.['phase_id'] ?? '');
578
+ await graphqlStep(c, 'update', M.phaseSettings(ctx.client, phaseId, { team_member_ids: c.after?.['team_member_ids'] }));
579
+ }
580
+ // ── 8. preferences and public form ─────────────────────────────────────────
581
+ for (const c of byEntityOp('repo_preferences', ['update', 'create'])) {
582
+ /**
583
+ * Two mutations, because the preference surface is split: the hidden-attribute
584
+ * arrays go to updateRepoPreferences (keyed on the repo uuid), and findable /
585
+ * inboxEmailEnabled / mainTabViews go through updatePipe.preferences. Anything
586
+ * else in the payload's repo_preferences row has no input at all.
587
+ */
588
+ await graphqlStep(c, 'update', M.updateRepoPreferences(ctx.client, ctx.repoUuid, c.after ?? {}));
589
+ await graphqlStep(c, 'update', M.updatePipePreferences(ctx.client, ctx.repoId, c.after ?? {}));
590
+ }
591
+ for (const c of byEntityOp('public_forms', ['update', 'create'])) {
592
+ await graphqlStep(c, 'update', M.updatePublicForm(ctx.client, ctx.repoId, c.after ?? {}));
593
+ }
594
+ // ── 9. deletes, in reverse dependency order ────────────────────────────────
595
+ for (const c of byEntityOp('automations', ['delete'])) {
596
+ emit({
597
+ changeKey: c.key,
598
+ entity: 'automations',
599
+ op: 'delete',
600
+ kind: 'graphql',
601
+ description: `deleteAutomation — ${c.label}`,
602
+ request: await M.deleteAutomation(ctx.client, String(c.before?.['id'] ?? '')),
603
+ needs: [],
604
+ destructive: true,
605
+ });
606
+ }
607
+ for (const c of byEntityOp('field_conditions', ['delete'])) {
608
+ await graphqlStep(c, 'delete', M.deleteFieldCondition(ctx.client, String(c.before?.['id'] ?? '')), { destructive: true });
609
+ }
610
+ for (const c of byEntityOp('fields', ['delete'])) {
611
+ await graphqlStep(c, 'delete', repoKind === 'table'
612
+ ? M.deleteTableField(ctx.client, ctx.repoId, c.before ?? {})
613
+ : M.deletePhaseField(ctx.client, c.before ?? {}, ctx.repoUuid), {
614
+ destructive: true,
615
+ });
616
+ }
617
+ for (const c of byEntityOp('phases', ['delete'])) {
618
+ if (!options.allowCardMoves) {
619
+ warnings.push(`deleting phase "${c.label}" can only proceed with --allow-card-moves: any cards in it must be moved first ` +
620
+ `(the recipes create an "Archive(Migration)" phase and move them)`);
621
+ continue;
622
+ }
623
+ await graphqlStep(c, 'delete', M.deletePhase(ctx.client, String(c.before?.['id'] ?? '')), { destructive: true });
624
+ }
625
+ for (const c of byEntityOp('labels', ['delete'])) {
626
+ await graphqlStep(c, 'delete', M.deleteLabel(ctx.client, String(c.before?.['id'] ?? '')), { destructive: true });
627
+ }
628
+ for (const c of byEntityOp('webhooks', ['delete'])) {
629
+ await graphqlStep(c, 'delete', M.deleteWebhook(ctx.client, String(c.before?.['id'] ?? '')), { destructive: true });
630
+ }
631
+ for (const c of byEntityOp('pipe_relations', ['delete'])) {
632
+ await graphqlStep(c, 'delete', M.deletePipeRelation(ctx.client, String(c.before?.['id'] ?? '')), { destructive: true });
633
+ }
634
+ // ── 10. field condition order, last: it references ids created above ───────
635
+ /**
636
+ * The order the workspace states, for every condition in the phase.
637
+ *
638
+ * A condition the workspace creates has no id yet, so it goes in as the
639
+ * placeholder its create step produces — matched by name, which is what the
640
+ * diff identifies a condition by too, since the payload row has no uuid.
641
+ */
642
+ const fcCreates = cs.changes.filter((c) => c.entity === 'field_conditions' && c.op === 'create' && supported(c.key));
643
+ for (const [phaseId, rows] of Object.entries(ctx.conditionOrder ?? {})) {
644
+ if (rows.length < 2)
645
+ continue;
646
+ const ids = rows.map((r) => {
647
+ if (r.id)
648
+ return String(r.id);
649
+ const created = fcCreates.find((c) => String(c.after?.['name'] ?? '') === r.name);
650
+ return created ? placeholderFor('field_conditions', created.key) : null;
651
+ });
652
+ /**
653
+ * An order containing something this plan cannot name is not written at
654
+ * all. A partial list would drop the unnamed condition out of the phase
655
+ * order, which is a change nobody asked for.
656
+ */
657
+ if (ids.some((id) => id === null)) {
658
+ warnings.push(`field condition order in phase ${phaseId} was left alone: ` +
659
+ `${ids.filter((id) => id === null).length} condition(s) in it are neither live nor created here`);
660
+ continue;
661
+ }
662
+ const baseline = ctx.baselineConditionOrder?.[phaseId] ?? [];
663
+ const stated = ids;
664
+ /**
665
+ * Only when the order differs from the one the pipe will already have.
666
+ *
667
+ * Re-sending an order the pipe already has is answered with "Something went
668
+ * wrong" — a failed step, and a failed apply, for a write with nothing to do.
669
+ *
670
+ * The previous guard compared against the baseline and gave up whenever a
671
+ * placeholder was present, on the grounds that a placeholder cannot be
672
+ * compared to a live id. But the server's placement is predictable: a newly
673
+ * created condition is appended after the ones already there. So the order to
674
+ * compare against is the baseline followed by this phase's creates in the
675
+ * order they are applied — which means appending a condition, the ordinary
676
+ * case, needs no reorder at all. Authoring it anywhere else still differs and
677
+ * is still written.
678
+ */
679
+ const appended = fcCreates
680
+ .filter((c) => String(c.after?.['phase_id'] ?? '') === String(phaseId))
681
+ .map((c) => placeholderFor('field_conditions', c.key));
682
+ /**
683
+ * Deletes shift the order too. Removing a condition leaves the rest in the
684
+ * order they were already in, so a delete on its own needs no reorder — and
685
+ * asking for the order that remains was answered with "Something went wrong",
686
+ * failing an apply whose real work had already succeeded.
687
+ */
688
+ const removed = new Set(cs.changes
689
+ .filter((c) => c.entity === 'field_conditions' && c.op === 'delete')
690
+ .map((c) => String(c.before?.['id'] ?? '')));
691
+ const natural = [...baseline.filter((id) => !removed.has(String(id))), ...appended];
692
+ const unchanged = natural.length === stated.length && stated.every((id, i) => id === natural[i]);
693
+ if (unchanged)
694
+ continue;
695
+ const anchor = fcCreates[0] ?? cs.changes.find((c) => c.entity === 'field_conditions');
696
+ if (!anchor)
697
+ continue;
698
+ await graphqlStep({ ...anchor, label: `field condition order in phase ${phaseId}` }, 'reorder', M.setFieldConditionOrder(ctx.client, phaseId, stated), { unverified: true });
699
+ }
700
+ debug(`compiled ${steps.length} steps from ${cs.changes.length} changes (${blocked.length} blocked)`);
701
+ const idMap = {};
702
+ for (const map of Object.values(cs.idMap)) {
703
+ for (const [k, v] of Object.entries(map ?? {}))
704
+ if (String(k) !== String(v))
705
+ idMap[k] = v;
706
+ }
707
+ return {
708
+ id: `${new Date().toISOString().replace(/[:.]/g, '-')}--${randomUUID().slice(0, 8)}`,
709
+ createdAt: new Date().toISOString(),
710
+ workspace: ctx.workspace,
711
+ repoId: ctx.repoId,
712
+ repoUuid: ctx.repoUuid,
713
+ mode: cs.mode,
714
+ options,
715
+ steps,
716
+ classifications,
717
+ blocked,
718
+ warnings,
719
+ idMap,
720
+ status: 'planned',
721
+ };
722
+ };