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