@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,399 @@
|
|
|
1
|
+
import { linkFlow, type FlowNode, type FlowTree } from '../codec/flow.ts';
|
|
2
|
+
import type { FlowChange, FlowChangeSet } from '../diff/flow.ts';
|
|
3
|
+
import type { FlowOperation } from '../pipefy/ipaas.ts';
|
|
4
|
+
import type { Row } from '../model/payload.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compiling flow changes into iPaaS operations.
|
|
8
|
+
*
|
|
9
|
+
* The operation set was probed against the live endpoint, and two facts shape
|
|
10
|
+
* everything here.
|
|
11
|
+
*
|
|
12
|
+
* **`IMPORT_FLOW` is never used.** It replaces the whole tree and runs the schema
|
|
13
|
+
* migration an old export needs, so a flow already in live form comes back with
|
|
14
|
+
* every datapill doubled — `{{step_1['output'].x}}` → `{{step_1['output']['output'].x}}` —
|
|
15
|
+
* and then resolves nothing at runtime while every validator still calls it valid.
|
|
16
|
+
* The granular operations do not migrate.
|
|
17
|
+
*
|
|
18
|
+
* **Four operations act on an empty request** instead of rejecting it:
|
|
19
|
+
* `CHANGE_STATUS`, `LOCK_AND_PUBLISH`, `LOCK_FLOW`, `CHANGE_FOLDER`,
|
|
20
|
+
* `UPDATE_METADATA`. Probing the enum by sending each with `request: {}`
|
|
21
|
+
* published and enabled a draft flow. Every request built here is complete and
|
|
22
|
+
* explicit; the client refuses an empty one for those types.
|
|
23
|
+
*
|
|
24
|
+
* Shapes, as the endpoint validated them:
|
|
25
|
+
*
|
|
26
|
+
* UPDATE_TRIGGER { name, type, displayName, valid, settings } — the whole node
|
|
27
|
+
* UPDATE_ACTION { name, type, displayName, valid, settings } — the whole node
|
|
28
|
+
* ADD_ACTION { parentStep, action: {...whole node...}, stepLocationRelativeToParent? }
|
|
29
|
+
* DELETE_ACTION { names: [ ... ] } — an array
|
|
30
|
+
* CHANGE_NAME { displayName }
|
|
31
|
+
* CHANGE_STATUS { status: 'ENABLED' | 'DISABLED' }
|
|
32
|
+
*
|
|
33
|
+
* A router's branches live in `settings.branches`, so editing them is an
|
|
34
|
+
* `UPDATE_ACTION` on the router. There is no `UPDATE_BRANCH` in the enum.
|
|
35
|
+
*
|
|
36
|
+
* **`stepLocationRelativeToParent` is optional only when the parent is not a
|
|
37
|
+
* loop.** Omitting it to add a step after a `LOOP_ON_ITEMS` — exiting the
|
|
38
|
+
* loop, as opposed to entering it — is refused with `Loop step parent
|
|
39
|
+
* undefined not found`; `'AFTER'` sent explicitly is what the same add needs.
|
|
40
|
+
* `'INSIDE_LOOP'` (entering) and `'INSIDE_BRANCH'` + `branchIndex` (a router
|
|
41
|
+
* child) both already required the field and both already worked. Measured
|
|
42
|
+
* on a throwaway flow (`research/ipaas/probe-loop-shape-3.ts`) after this
|
|
43
|
+
* exact shape failed live building the first flow this tool ever wrote with
|
|
44
|
+
* a loop in it — every flow before had none.
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
export type FlowStep = {
|
|
48
|
+
id: string;
|
|
49
|
+
index: number;
|
|
50
|
+
changeKey: string;
|
|
51
|
+
/** Which flow this acts on. Null when the flow is created by an earlier step. */
|
|
52
|
+
flowId: string | null;
|
|
53
|
+
/** For a create: the display name to create the flow with. */
|
|
54
|
+
createFlow?: { displayName: string; tree: FlowTree };
|
|
55
|
+
/** For a delete of the whole flow. */
|
|
56
|
+
deleteFlow?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* A connection a flow references that the project does not have.
|
|
59
|
+
*
|
|
60
|
+
* Created with a placeholder credential so the flow can be built: the
|
|
61
|
+
* structure is what is being authored and it does not depend on the
|
|
62
|
+
* credential working. The flow stays DISABLED, and the apply reports which
|
|
63
|
+
* connections still need reconnecting.
|
|
64
|
+
*/
|
|
65
|
+
createConnection?: { pieceName: string; displayName: string; externalId: string };
|
|
66
|
+
operation?: { type: FlowOperation; request: Record<string, unknown> };
|
|
67
|
+
description: string;
|
|
68
|
+
destructive?: boolean;
|
|
69
|
+
/** Set when the step would arm a flow against live traffic. */
|
|
70
|
+
arms?: boolean;
|
|
71
|
+
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped';
|
|
72
|
+
error?: string;
|
|
73
|
+
result?: unknown;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type FlowPlan = {
|
|
77
|
+
steps: FlowStep[];
|
|
78
|
+
/** Changes that cannot be applied, with the reason. */
|
|
79
|
+
blocked: Array<{ key: string; label: string; reason: string }>;
|
|
80
|
+
warnings: string[];
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The node as an operation request: the whole node, minus link and volatile keys. */
|
|
84
|
+
const nodeRequest = (node: FlowNode): Record<string, unknown> => {
|
|
85
|
+
const { _nextAction, _firstLoopAction, _children, ...rest } = node;
|
|
86
|
+
const out: Record<string, unknown> = {
|
|
87
|
+
name: rest['name'],
|
|
88
|
+
type: rest['type'],
|
|
89
|
+
valid: rest['valid'] ?? true,
|
|
90
|
+
settings: rest['settings'] ?? {},
|
|
91
|
+
};
|
|
92
|
+
if (rest['displayName'] !== undefined) out['displayName'] = rest['displayName'];
|
|
93
|
+
if (rest['skip'] !== undefined) out['skip'] = rest['skip'];
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where a new node hangs. `ADD_ACTION` takes the parent it follows, plus a
|
|
99
|
+
* location for the cases where "after" is ambiguous.
|
|
100
|
+
*
|
|
101
|
+
* A bare `{ parentStep }` with no location is only unambiguous when the
|
|
102
|
+
* parent is a plain step. When the parent is itself a `LOOP_ON_ITEMS`,
|
|
103
|
+
* "after it" could mean *inside* the loop or *exiting* it, and the endpoint
|
|
104
|
+
* refuses to guess: measured live (`research/ipaas/probe-loop-shape-3.ts`),
|
|
105
|
+
* the identical omitted-location request answers `Loop step parent undefined
|
|
106
|
+
* not found`, and adding `stepLocationRelativeToParent: 'AFTER'` is what
|
|
107
|
+
* makes the same add succeed. Every flow this tool wrote before had no loop
|
|
108
|
+
* at all, so this was never exercised.
|
|
109
|
+
*/
|
|
110
|
+
const parentOf = (tree: FlowTree, nodeName: string): { parentStep: string; location?: string; branchIndex?: number } | null => {
|
|
111
|
+
for (const candidate of tree.nodes) {
|
|
112
|
+
if (candidate['_nextAction'] === nodeName) {
|
|
113
|
+
return { parentStep: candidate.name, ...(candidate['type'] === 'LOOP_ON_ITEMS' ? { location: 'AFTER' } : {}) };
|
|
114
|
+
}
|
|
115
|
+
if (candidate['_firstLoopAction'] === nodeName) {
|
|
116
|
+
return { parentStep: candidate.name, location: 'INSIDE_LOOP' };
|
|
117
|
+
}
|
|
118
|
+
const children = (candidate['_children'] as Array<string | null> | undefined) ?? [];
|
|
119
|
+
const at = children.indexOf(nodeName);
|
|
120
|
+
if (at >= 0) return { parentStep: candidate.name, location: 'INSIDE_BRANCH', branchIndex: at };
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** Every `{{connections['x']}}` a flow references, with the piece that needs it. */
|
|
126
|
+
export const connectionsUsedBy = (tree: FlowTree): Array<{ externalId: string; pieceName: string }> => {
|
|
127
|
+
const out = new Map<string, string>();
|
|
128
|
+
const CONN = /connections\['([^']+)'\]/g;
|
|
129
|
+
for (const node of tree.nodes) {
|
|
130
|
+
const settings = (node['settings'] ?? {}) as Record<string, unknown>;
|
|
131
|
+
const piece = String(settings['pieceName'] ?? '');
|
|
132
|
+
const text = JSON.stringify(settings['input'] ?? {});
|
|
133
|
+
for (const m of text.matchAll(CONN)) {
|
|
134
|
+
const id = m[1] as string;
|
|
135
|
+
if (!out.has(id)) out.set(id, piece);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return [...out].map(([externalId, pieceName]) => ({ externalId, pieceName }));
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export const compileFlows = (
|
|
142
|
+
cs: FlowChangeSet,
|
|
143
|
+
opts: {
|
|
144
|
+
allowArm?: boolean;
|
|
145
|
+
/** externalId -> the connection the project already has. */
|
|
146
|
+
connections?: Map<string, { pieceName: string; status: string }>;
|
|
147
|
+
} = {},
|
|
148
|
+
): FlowPlan => {
|
|
149
|
+
const steps: FlowStep[] = [];
|
|
150
|
+
const blocked: FlowPlan['blocked'] = [];
|
|
151
|
+
const warnings: string[] = [];
|
|
152
|
+
|
|
153
|
+
const emit = (partial: Omit<FlowStep, 'id' | 'index' | 'status'>) => {
|
|
154
|
+
steps.push({ id: `f${String(steps.length + 1).padStart(3, '0')}`, index: steps.length, status: 'pending', ...partial });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Order matters and is not the diff's order.
|
|
159
|
+
*
|
|
160
|
+
* Creates before the changes that reference them; node deletes before node
|
|
161
|
+
* adds, so a rename that is really a delete-plus-add does not collide on a
|
|
162
|
+
* name; and status last, because arming a flow should be the final thing that
|
|
163
|
+
* happens to it, after its contents are what they should be.
|
|
164
|
+
*/
|
|
165
|
+
const order: Record<FlowChange['op'], number> = {
|
|
166
|
+
'flow.create': 0,
|
|
167
|
+
'flow.rename': 1,
|
|
168
|
+
'node.delete': 2,
|
|
169
|
+
'trigger.update': 3,
|
|
170
|
+
'node.create': 4,
|
|
171
|
+
'node.update': 5,
|
|
172
|
+
'flow.delete': 6,
|
|
173
|
+
'flow.status': 7,
|
|
174
|
+
};
|
|
175
|
+
const sorted = [...cs.changes].sort((a, b) => order[a.op] - order[b.op]);
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Connections first: a flow cannot be created referencing one the project does
|
|
179
|
+
* not have. A missing connection used to be a refusal, which stalled the whole
|
|
180
|
+
* build for something the flow structure does not depend on.
|
|
181
|
+
*/
|
|
182
|
+
if (opts.connections) {
|
|
183
|
+
const needed = new Map<string, string>();
|
|
184
|
+
for (const c of cs.changes) {
|
|
185
|
+
const tree = (c.afterFlow ?? null) as FlowTree | null;
|
|
186
|
+
if (!tree) continue;
|
|
187
|
+
for (const use of connectionsUsedBy(tree)) {
|
|
188
|
+
if (opts.connections.has(use.externalId)) continue;
|
|
189
|
+
if (!needed.has(use.externalId)) needed.set(use.externalId, use.pieceName);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const [externalId, pieceName] of needed) {
|
|
193
|
+
emit({
|
|
194
|
+
changeKey: `connection:${externalId}`,
|
|
195
|
+
flowId: null,
|
|
196
|
+
createConnection: {
|
|
197
|
+
pieceName,
|
|
198
|
+
externalId,
|
|
199
|
+
displayName: `${pieceName.split('/').pop()} (placeholder, reconnect me)`,
|
|
200
|
+
},
|
|
201
|
+
description: `create placeholder connection ${externalId} for ${pieceName}`,
|
|
202
|
+
});
|
|
203
|
+
warnings.push(
|
|
204
|
+
`connection ${externalId} does not exist in this project and will be created with a ` +
|
|
205
|
+
'placeholder credential — reconnect it in the product before enabling any flow that uses it',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const c of sorted) {
|
|
211
|
+
switch (c.op) {
|
|
212
|
+
case 'flow.create': {
|
|
213
|
+
const tree = c.afterFlow as FlowTree;
|
|
214
|
+
emit({
|
|
215
|
+
changeKey: c.key,
|
|
216
|
+
flowId: null,
|
|
217
|
+
createFlow: { displayName: c.flowName, tree },
|
|
218
|
+
description: `create flow "${c.flowName}" and add its ${tree.nodes.length} nodes`,
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
case 'flow.delete': {
|
|
224
|
+
emit({
|
|
225
|
+
changeKey: c.key,
|
|
226
|
+
flowId: c.flowId,
|
|
227
|
+
deleteFlow: true,
|
|
228
|
+
description: `delete flow "${c.flowName}"`,
|
|
229
|
+
destructive: true,
|
|
230
|
+
});
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case 'flow.rename': {
|
|
235
|
+
emit({
|
|
236
|
+
changeKey: c.key,
|
|
237
|
+
flowId: c.flowId,
|
|
238
|
+
operation: { type: 'CHANGE_NAME', request: { displayName: c.flowName } },
|
|
239
|
+
description: `CHANGE_NAME — "${c.flowName}"`,
|
|
240
|
+
});
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'flow.status': {
|
|
245
|
+
const status = String(c.deltas[0]?.after ?? '');
|
|
246
|
+
if (status !== 'ENABLED' && status !== 'DISABLED') {
|
|
247
|
+
blocked.push({ key: c.key, label: c.label, reason: `status ${JSON.stringify(status)} is not ENABLED or DISABLED` });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Enabling a flow points it at live traffic. It is refused unless asked
|
|
252
|
+
* for explicitly, the same way a phase delete needs --allow-card-moves:
|
|
253
|
+
* the cost of a wrong one is paid by the client, not by the person running
|
|
254
|
+
* the apply.
|
|
255
|
+
*/
|
|
256
|
+
const usesPlaceholder = [...(opts.connections ?? new Map())].some(
|
|
257
|
+
([, c]) => (c as { status: string })['status'] === 'PLACEHOLDER',
|
|
258
|
+
);
|
|
259
|
+
if (status === 'ENABLED' && usesPlaceholder) {
|
|
260
|
+
blocked.push({
|
|
261
|
+
key: c.key,
|
|
262
|
+
label: c.label,
|
|
263
|
+
reason:
|
|
264
|
+
'this project has a placeholder connection — reconnect it before enabling a flow, ' +
|
|
265
|
+
'or the flow will run and fail on every trigger',
|
|
266
|
+
});
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (status === 'ENABLED' && !opts.allowArm) {
|
|
270
|
+
blocked.push({
|
|
271
|
+
key: c.key,
|
|
272
|
+
label: c.label,
|
|
273
|
+
reason:
|
|
274
|
+
'enabling a flow arms it against live traffic — re-run with --allow-flow-enable to do it deliberately',
|
|
275
|
+
});
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
emit({
|
|
279
|
+
changeKey: c.key,
|
|
280
|
+
flowId: c.flowId,
|
|
281
|
+
operation: { type: 'CHANGE_STATUS', request: { status } },
|
|
282
|
+
description: `CHANGE_STATUS — ${status}`,
|
|
283
|
+
arms: status === 'ENABLED',
|
|
284
|
+
});
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case 'trigger.update': {
|
|
289
|
+
emit({
|
|
290
|
+
changeKey: c.key,
|
|
291
|
+
flowId: c.flowId,
|
|
292
|
+
operation: { type: 'UPDATE_TRIGGER', request: nodeRequest(c.after as FlowNode) },
|
|
293
|
+
description: `UPDATE_TRIGGER — flow "${c.flowName}"`,
|
|
294
|
+
});
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case 'node.update': {
|
|
299
|
+
emit({
|
|
300
|
+
changeKey: c.key,
|
|
301
|
+
flowId: c.flowId,
|
|
302
|
+
operation: { type: 'UPDATE_ACTION', request: nodeRequest(c.after as FlowNode) },
|
|
303
|
+
description: `UPDATE_ACTION — ${c.nodeName} in "${c.flowName}"`,
|
|
304
|
+
});
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case 'node.delete': {
|
|
309
|
+
emit({
|
|
310
|
+
changeKey: c.key,
|
|
311
|
+
flowId: c.flowId,
|
|
312
|
+
// `names` is an array — a single `name` is rejected.
|
|
313
|
+
operation: { type: 'DELETE_ACTION', request: { names: [c.nodeName] } },
|
|
314
|
+
description: `DELETE_ACTION — ${c.nodeName} in "${c.flowName}"`,
|
|
315
|
+
destructive: true,
|
|
316
|
+
});
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
case 'node.create': {
|
|
321
|
+
const tree = c.afterFlow as FlowTree | undefined;
|
|
322
|
+
const where = tree ? parentOf(tree, c.nodeName as string) : null;
|
|
323
|
+
if (!where) {
|
|
324
|
+
// Unreachable from the chain — nothing points `_nextAction` /
|
|
325
|
+
// `_firstLoopAction` / a branch's `_children` at this node, so there is
|
|
326
|
+
// no parent to hang it from. The same case `buildFlowOperations` skips
|
|
327
|
+
// for a brand-new flow.
|
|
328
|
+
blocked.push({
|
|
329
|
+
key: c.key,
|
|
330
|
+
label: c.label,
|
|
331
|
+
reason:
|
|
332
|
+
`${c.nodeName} is not reachable from the trigger — nothing in the authored flow ` +
|
|
333
|
+
'points to it, so there is no parent to add it after.',
|
|
334
|
+
});
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
emit({
|
|
338
|
+
changeKey: c.key,
|
|
339
|
+
flowId: c.flowId,
|
|
340
|
+
operation: {
|
|
341
|
+
type: 'ADD_ACTION',
|
|
342
|
+
request: {
|
|
343
|
+
parentStep: where.parentStep,
|
|
344
|
+
...(where.location ? { stepLocationRelativeToParent: where.location } : {}),
|
|
345
|
+
...(where.branchIndex !== undefined ? { branchIndex: where.branchIndex } : {}),
|
|
346
|
+
action: nodeRequest(c.after as FlowNode),
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
description: `ADD_ACTION — ${c.nodeName} [${(c.after as FlowNode).type}] after ${where.parentStep}${where.location ? ` (${where.location})` : ''} in "${c.flowName}"`,
|
|
350
|
+
});
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (blocked.length) {
|
|
357
|
+
warnings.push(`${blocked.length} flow change(s) have no write path yet`);
|
|
358
|
+
}
|
|
359
|
+
return { steps, blocked, warnings };
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The operations that build a brand-new flow's contents.
|
|
364
|
+
*
|
|
365
|
+
* A created flow arrives with an `EMPTY` trigger and nothing else, so the tree is
|
|
366
|
+
* laid down trigger-first and then each node in execution order — every node's
|
|
367
|
+
* parent already exists by the time it is added, because execution order is a
|
|
368
|
+
* topological order of the chain.
|
|
369
|
+
*/
|
|
370
|
+
export const buildFlowOperations = (tree: FlowTree): Array<{ type: FlowOperation; request: Record<string, unknown>; describe: string }> => {
|
|
371
|
+
const ops: Array<{ type: FlowOperation; request: Record<string, unknown>; describe: string }> = [];
|
|
372
|
+
const trigger = tree.nodes[0];
|
|
373
|
+
if (!trigger) return ops;
|
|
374
|
+
|
|
375
|
+
ops.push({ type: 'UPDATE_TRIGGER', request: nodeRequest(trigger), describe: `trigger [${trigger.type}]` });
|
|
376
|
+
|
|
377
|
+
for (const node of tree.nodes.slice(1)) {
|
|
378
|
+
const where = parentOf(tree, node.name);
|
|
379
|
+
if (!where) {
|
|
380
|
+
// Unreachable from the chain: the codec would have dropped it anyway, and
|
|
381
|
+
// adding it with a guessed parent would put it somewhere nobody asked for.
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
ops.push({
|
|
385
|
+
type: 'ADD_ACTION',
|
|
386
|
+
request: {
|
|
387
|
+
parentStep: where.parentStep,
|
|
388
|
+
...(where.location ? { stepLocationRelativeToParent: where.location } : {}),
|
|
389
|
+
...(where.branchIndex !== undefined ? { branchIndex: where.branchIndex } : {}),
|
|
390
|
+
action: nodeRequest(node),
|
|
391
|
+
},
|
|
392
|
+
describe: `${node.name} [${node.type}] after ${where.parentStep}${where.location ? ` (${where.location})` : ''}`,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return ops;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
/** The API flow row a tree would produce, for verification after an apply. */
|
|
399
|
+
export const intendedFlow = (tree: FlowTree): Row => linkFlow(tree);
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { debug } from '../util/log.ts';
|
|
2
|
+
import type { Placeholder } from './types.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Id substitution — the single subtlest thing in the write path.
|
|
6
|
+
*
|
|
7
|
+
* Two jobs, both learned from the Change Migrator recipes:
|
|
8
|
+
*
|
|
9
|
+
* 1. **Placeholders.** A new field has no id until it is created, so an
|
|
10
|
+
* automation created in the same plan references `%{_new:<key>}` and the
|
|
11
|
+
* executor swaps in the real id once the field exists. PLAN.md §9 flags
|
|
12
|
+
* that this "fails only on the combination create field + reference it in
|
|
13
|
+
* the same plan" — i.e. constantly.
|
|
14
|
+
*
|
|
15
|
+
* 2. **Cross-repo remapping.** Recipe step 120 rewrites every dev field id
|
|
16
|
+
* inside an automation's JSON to its prod equivalent, looking each one up
|
|
17
|
+
* through a Pipefy table with one API call per id. Same transformation
|
|
18
|
+
* here, from the map the diff already built, with no API calls.
|
|
19
|
+
*
|
|
20
|
+
* Ids hide inside opaque JSON: `"%{<fieldId>}"`, `"field_address": "<fieldId>"`,
|
|
21
|
+
* `"trigger_field_ids": ["<fieldId>"]`. So substitution walks the whole
|
|
22
|
+
* structure. `%{...}` is rewritten anywhere it appears; a bare id is rewritten
|
|
23
|
+
* only under a key known to hold one, because rewriting every number that
|
|
24
|
+
* happens to match an id would corrupt values that merely look like ids.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Keys whose values are ids, and may therefore be remapped when bare. */
|
|
28
|
+
export const ID_BEARING_KEYS = new Set([
|
|
29
|
+
'field_address',
|
|
30
|
+
'field_id',
|
|
31
|
+
'fieldId',
|
|
32
|
+
'field_ids',
|
|
33
|
+
'fieldIds',
|
|
34
|
+
'phase_id',
|
|
35
|
+
'phaseId',
|
|
36
|
+
'phaseFieldId',
|
|
37
|
+
'to_phase_id',
|
|
38
|
+
'toPhaseId',
|
|
39
|
+
'from_phase_id',
|
|
40
|
+
'fromPhaseId',
|
|
41
|
+
'in_phase_id',
|
|
42
|
+
'inPhaseId',
|
|
43
|
+
'trigger_field_ids',
|
|
44
|
+
'triggerFieldIds',
|
|
45
|
+
'trigger_automation_id',
|
|
46
|
+
'triggerAutomationId',
|
|
47
|
+
'card_id',
|
|
48
|
+
'cardId',
|
|
49
|
+
'title_field_id',
|
|
50
|
+
'connected_pipe_id',
|
|
51
|
+
'connected_repo_id',
|
|
52
|
+
'submitter_email_field_id',
|
|
53
|
+
'email_template_id',
|
|
54
|
+
'source_id',
|
|
55
|
+
'owner_id',
|
|
56
|
+
'skills_ids',
|
|
57
|
+
'skillsIds',
|
|
58
|
+
'jump_target_ids',
|
|
59
|
+
'target_phase_id',
|
|
60
|
+
'source_phase_id',
|
|
61
|
+
// Id *lists* count too. Omitting fieldConditionIds meant a placeholder inside
|
|
62
|
+
// the array was never substituted and went to the API verbatim, which came
|
|
63
|
+
// back as "Invalid input: fieldConditionIds ([0])" — an error that says
|
|
64
|
+
// nothing about the real cause.
|
|
65
|
+
'fieldConditionIds',
|
|
66
|
+
'field_condition_ids',
|
|
67
|
+
/**
|
|
68
|
+
* The field ids an `update_card_field` automation writes, in order.
|
|
69
|
+
*
|
|
70
|
+
* Fourth omission from this list, and the most quietly damaging: nothing
|
|
71
|
+
* rejected it. `fields_map_order: ["%{433584976}"]` was stored by the API
|
|
72
|
+
* verbatim, braces and all. The automation was created, reported valid,
|
|
73
|
+
* verified clean, and selected **no fields to update** — because the UI and the
|
|
74
|
+
* engine read this list to know which fields the automation writes, and
|
|
75
|
+
* `"%{433584976}"` is not a field id. Every working automation in the org
|
|
76
|
+
* stores bare ids here.
|
|
77
|
+
*
|
|
78
|
+
* `field_map[].field_id` was substituted correctly the whole time, so the
|
|
79
|
+
* automation looked right in the workspace and did nothing on the pipe.
|
|
80
|
+
*/
|
|
81
|
+
'fields_map_order',
|
|
82
|
+
'fieldsMapOrder',
|
|
83
|
+
/**
|
|
84
|
+
* The internal settings endpoint's form key, brackets and all.
|
|
85
|
+
*
|
|
86
|
+
* Substitution is keyed on the *key name*, and this one is
|
|
87
|
+
* `phase[jump_target_ids][]` — nothing like the camel or snake spellings above.
|
|
88
|
+
* Without it a target naming a phase created in the same plan was rewritten to
|
|
89
|
+
* `%{344123894}` instead of `344123894`, and the endpoint answered 404 with no
|
|
90
|
+
* hint about which value it disliked. Third time this list has been the cause;
|
|
91
|
+
* `runInternalRestStep` now asserts on the value shape as well, so a fourth
|
|
92
|
+
* omission fails loudly instead.
|
|
93
|
+
*/
|
|
94
|
+
'phase[jump_target_ids][]',
|
|
95
|
+
'teamMemberIds',
|
|
96
|
+
'team_member_ids',
|
|
97
|
+
'phaseFieldIds',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Captures the whole token including the `_new:` prefix, because that is the key
|
|
102
|
+
* placeholders are *stored* under (`placeholderFor` emits `_new:fields:<key>`).
|
|
103
|
+
* Capturing only the tail silently makes every lookup miss.
|
|
104
|
+
*/
|
|
105
|
+
const PLACEHOLDER_RE = /%\{(_new:[^}]+)\}/g;
|
|
106
|
+
const REFERENCE_RE = /%\{(\d+)\}/g;
|
|
107
|
+
|
|
108
|
+
export const placeholderFor = (entity: string, key: string): Placeholder => `_new:${entity}:${key}`;
|
|
109
|
+
|
|
110
|
+
export const placeholderToken = (p: Placeholder): string => `%{${p}}`;
|
|
111
|
+
|
|
112
|
+
export class IdMap {
|
|
113
|
+
/** placeholder → real id, filled in as creating steps succeed. */
|
|
114
|
+
private placeholders = new Map<string, number>();
|
|
115
|
+
/** old id → new id, for cross-repo migration. */
|
|
116
|
+
private remap = new Map<string, number>();
|
|
117
|
+
|
|
118
|
+
constructor(seed: Record<string, number> = {}) {
|
|
119
|
+
for (const [k, v] of Object.entries(seed)) this.remap.set(String(k), Number(v));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resolvePlaceholder(p: Placeholder, id: number, aliases: Placeholder[] = []) {
|
|
123
|
+
this.placeholders.set(p, id);
|
|
124
|
+
for (const a of aliases) if (a) this.placeholders.set(a, id);
|
|
125
|
+
debug(`resolved placeholder ${p}${aliases.length ? ` (+${aliases.length} alias)` : ''} -> ${id}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
hasPlaceholder(p: Placeholder) {
|
|
129
|
+
return this.placeholders.has(p);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
placeholderValue(p: Placeholder): number | undefined {
|
|
133
|
+
return this.placeholders.get(p);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
addRemap(oldId: string | number, newId: number) {
|
|
137
|
+
this.remap.set(String(oldId), newId);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
lookup(id: string | number): number | undefined {
|
|
141
|
+
return this.remap.get(String(id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Which placeholders a value still depends on. Empty means it is ready to send. */
|
|
145
|
+
unresolved(value: unknown): Placeholder[] {
|
|
146
|
+
const out = new Set<string>();
|
|
147
|
+
const walk = (v: unknown) => {
|
|
148
|
+
if (typeof v === 'string') {
|
|
149
|
+
for (const m of v.matchAll(PLACEHOLDER_RE)) {
|
|
150
|
+
const key = m[1] as string;
|
|
151
|
+
if (!this.placeholders.has(key)) out.add(key);
|
|
152
|
+
}
|
|
153
|
+
// A bare placeholder, as compile.ts emits for phase_id on a new field.
|
|
154
|
+
if (v.startsWith('_new:') && !this.placeholders.has(v)) out.add(v);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (Array.isArray(v)) {
|
|
158
|
+
v.forEach(walk);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (v && typeof v === 'object') Object.values(v as Record<string, unknown>).forEach(walk);
|
|
162
|
+
};
|
|
163
|
+
walk(value);
|
|
164
|
+
return [...out];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private rewriteString(s: string): string {
|
|
168
|
+
let out = s.replace(PLACEHOLDER_RE, (whole, key: string) => {
|
|
169
|
+
const id = this.placeholders.get(key);
|
|
170
|
+
return id === undefined ? whole : `%{${id}}`;
|
|
171
|
+
});
|
|
172
|
+
out = out.replace(REFERENCE_RE, (whole, id: string) => {
|
|
173
|
+
const mapped = this.remap.get(id);
|
|
174
|
+
return mapped === undefined ? whole : `%{${mapped}}`;
|
|
175
|
+
});
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A bare id value, remapped if it is under an id-bearing key. */
|
|
180
|
+
private rewriteBare(v: unknown): unknown {
|
|
181
|
+
if (typeof v === 'number') {
|
|
182
|
+
const mapped = this.remap.get(String(v));
|
|
183
|
+
return mapped ?? v;
|
|
184
|
+
}
|
|
185
|
+
if (typeof v === 'string') {
|
|
186
|
+
if (/^_new:/.test(v)) {
|
|
187
|
+
const id = this.placeholders.get(v);
|
|
188
|
+
return id === undefined ? v : String(id);
|
|
189
|
+
}
|
|
190
|
+
if (/^\d+$/.test(v)) {
|
|
191
|
+
const mapped = this.remap.get(v);
|
|
192
|
+
return mapped === undefined ? v : String(mapped);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A value under an id-bearing key that is *entirely* one reference is an
|
|
197
|
+
* id, not a template: `"phase_id": "%{_new:<uuid>}"` must become `"344…"`,
|
|
198
|
+
* not `"%{344…}"`. Only `createFieldCondition` and friends care, but they
|
|
199
|
+
* care absolutely — a braced id is rejected as a malformed ID.
|
|
200
|
+
*/
|
|
201
|
+
const whole = /^%\{(_new:[^}]+|\d+)\}$/.exec(v);
|
|
202
|
+
if (whole) {
|
|
203
|
+
const key = whole[1] as string;
|
|
204
|
+
const resolved = key.startsWith('_new:') ? this.placeholders.get(key) : this.remap.get(key);
|
|
205
|
+
if (resolved !== undefined) return String(resolved);
|
|
206
|
+
// A numeric reference with no remapping is already the id it names.
|
|
207
|
+
if (!key.startsWith('_new:')) return key;
|
|
208
|
+
return v;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return this.rewriteString(v);
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(v)) return v.map((x) => this.rewriteBare(x));
|
|
214
|
+
return v;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Deep-rewrite a request payload. Returns a new value; the input is untouched. */
|
|
218
|
+
rewrite<T>(value: T): T {
|
|
219
|
+
const walk = (v: unknown, keyIsIdBearing: boolean): unknown => {
|
|
220
|
+
if (typeof v === 'string') return keyIsIdBearing ? this.rewriteBare(v) : this.rewriteString(v);
|
|
221
|
+
if (typeof v === 'number') return keyIsIdBearing ? this.rewriteBare(v) : v;
|
|
222
|
+
if (Array.isArray(v)) return v.map((x) => walk(x, keyIsIdBearing));
|
|
223
|
+
if (v && typeof v === 'object') {
|
|
224
|
+
const out: Record<string, unknown> = {};
|
|
225
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
226
|
+
out[k] = walk(val, ID_BEARING_KEYS.has(k));
|
|
227
|
+
}
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
return v;
|
|
231
|
+
};
|
|
232
|
+
return walk(value, false) as T;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
snapshot() {
|
|
236
|
+
return {
|
|
237
|
+
placeholders: Object.fromEntries(this.placeholders),
|
|
238
|
+
remap: Object.fromEntries(this.remap),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|