@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,277 @@
1
+ import { writeJson } from "../util/fsx.js";
2
+ import { debug, dim, green, red, yellow } from "../util/log.js";
3
+ import { Progress } from "../util/tui.js";
4
+ import { IdMap } from "./idmap.js";
5
+ const pluck = (obj, path) => {
6
+ let cur = obj;
7
+ for (const key of path) {
8
+ if (cur && typeof cur === 'object' && key in cur) {
9
+ cur = cur[key];
10
+ }
11
+ else {
12
+ return undefined;
13
+ }
14
+ }
15
+ return cur;
16
+ };
17
+ export const execute = async (plan, opts) => {
18
+ const idMap = new IdMap(plan.idMap);
19
+ plan.status = 'running';
20
+ /**
21
+ * The step list, live. A plan is a queue of network calls whose count and
22
+ * order the user has already seen in `pipe plan`, so showing that same list
23
+ * fill in is the honest progress indicator — and in a dry run it shows exactly
24
+ * what would be sent, in order, without sending it.
25
+ */
26
+ const ui = new Progress({
27
+ title: opts.dryRun ? `dry run · ${plan.steps.length} steps` : `applying ${plan.steps.length} steps`,
28
+ });
29
+ for (const s of plan.steps) {
30
+ ui.add({
31
+ id: s.id,
32
+ label: s.description,
33
+ state: s.status === 'done' ? 'done' : 'pending',
34
+ tags: [
35
+ ...(s.destructive ? ['destructive'] : []),
36
+ ...(s.unverified ? ['unverified'] : []),
37
+ ...(s.kind.startsWith('internal') ? ['internal-api'] : []),
38
+ ],
39
+ });
40
+ }
41
+ ui.start();
42
+ const persist = async () => {
43
+ if (!opts.dryRun)
44
+ await writeJson(opts.planPath, plan);
45
+ };
46
+ for (const s of plan.steps) {
47
+ if (s.status === 'done') {
48
+ // Resume: a completed create still has to re-register its placeholder so
49
+ // later steps can resolve against it.
50
+ if (s.produces && s.result !== undefined) {
51
+ const id = Number(pluck(s.result, s.produces.path));
52
+ if (Number.isFinite(id))
53
+ idMap.resolvePlaceholder(s.produces.placeholder, id, s.produces.aliases ?? []);
54
+ }
55
+ continue;
56
+ }
57
+ const unresolved = idMap.unresolved(s.request.variables ?? s.request.form ?? s.request.path ?? null);
58
+ if (unresolved.length) {
59
+ s.status = 'skipped';
60
+ s.error = `depends on ids that were never created: ${unresolved.join(', ')}`;
61
+ ui.update(s.id, { state: 'skipped', detail: s.error });
62
+ await persist();
63
+ continue;
64
+ }
65
+ const request = {
66
+ ...s.request,
67
+ variables: s.request.variables ? idMap.rewrite(s.request.variables) : undefined,
68
+ form: s.request.form ? idMap.rewrite(s.request.form) : undefined,
69
+ path: s.request.path ? idMap.rewrite({ p: s.request.path }).p : undefined,
70
+ };
71
+ /**
72
+ * Nothing leaves here still carrying a placeholder.
73
+ *
74
+ * A *registered* placeholder passes the unresolved check above, but is only
75
+ * substituted if its key is known to hold an id — so a new id-bearing key
76
+ * that nobody added to ID_BEARING_KEYS sends the marker to the API verbatim.
77
+ * That happened with `fieldConditionIds`, and the resulting error named the
78
+ * field but said nothing about the cause. This refuses to send, and says
79
+ * which key.
80
+ */
81
+ const leftovers = [];
82
+ const findMarkers = (v, path) => {
83
+ if (typeof v === 'string') {
84
+ if (v.includes('_new:'))
85
+ leftovers.push(`${path} = ${v}`);
86
+ return;
87
+ }
88
+ if (Array.isArray(v)) {
89
+ v.forEach((x, i) => findMarkers(x, `${path}[${i}]`));
90
+ return;
91
+ }
92
+ if (v && typeof v === 'object') {
93
+ for (const [k, val] of Object.entries(v)) {
94
+ findMarkers(val, path ? `${path}.${k}` : k);
95
+ }
96
+ }
97
+ };
98
+ findMarkers(request.variables ?? null, '');
99
+ findMarkers(request.form ?? null, '');
100
+ if (leftovers.length) {
101
+ s.status = 'failed';
102
+ s.error =
103
+ `unsubstituted placeholder at ${leftovers.join(', ')} — that key is not in ID_BEARING_KEYS, ` +
104
+ 'so nothing replaced it. Not sent.';
105
+ s.finishedAt = new Date().toISOString();
106
+ ui.update(s.id, { state: 'failed', detail: s.error });
107
+ await persist();
108
+ if (!opts.continueOnError)
109
+ break;
110
+ continue;
111
+ }
112
+ if (opts.dryRun) {
113
+ s.status = 'skipped';
114
+ /**
115
+ * A dry run marks each step done in the display rather than skipped: the
116
+ * step is complete as far as this run goes, and painting a whole plan
117
+ * yellow suggests something went wrong when nothing did. The title says
118
+ * "dry run", and nothing was sent.
119
+ */
120
+ ui.update(s.id, { state: 'done', detail: 'not sent' });
121
+ debug(JSON.stringify(request.variables ?? request.form ?? {}).slice(0, 400));
122
+ opts.onProgress?.(s, plan);
123
+ continue;
124
+ }
125
+ s.status = 'running';
126
+ s.startedAt = new Date().toISOString();
127
+ await persist();
128
+ ui.update(s.id, { state: 'running' });
129
+ try {
130
+ let result;
131
+ if (s.kind === 'graphql') {
132
+ try {
133
+ result = await opts.client.raw(request.query, request.variables, s.request.mutation);
134
+ }
135
+ catch (e) {
136
+ /**
137
+ * `phaseSettings` is known to answer "Something went wrong" for a change
138
+ * it has just made — observed twice, on two different runs, with the
139
+ * `phase_team_memberships` row present in the read-back both times and
140
+ * timestamped to the failing call. Its failures are therefore
141
+ * inconclusive, not negative, and saying so is the difference between
142
+ * an operator re-running a harmless write and an operator hunting a
143
+ * change that already exists.
144
+ */
145
+ if (s.request.mutation === 'phaseSettings') {
146
+ throw new Error(`${e.message} — note that phaseSettings has been observed reporting this ` +
147
+ 'for a change it did apply. Re-read the phase before assuming nothing happened.');
148
+ }
149
+ throw e;
150
+ }
151
+ }
152
+ else if (s.kind === 'internal-graphql') {
153
+ result = await runInternalAutomationStep(opts.internal, s, request.variables ?? {});
154
+ }
155
+ else if (s.kind === 'internal-rest') {
156
+ result = await runInternalRestStep(opts.internal, s, request);
157
+ }
158
+ else {
159
+ result = { noop: true };
160
+ }
161
+ s.result = result;
162
+ s.status = 'done';
163
+ s.finishedAt = new Date().toISOString();
164
+ ui.update(s.id, { state: 'done' });
165
+ if (s.produces) {
166
+ const raw = pluck(result, s.produces.path);
167
+ const id = Number(raw);
168
+ if (Number.isFinite(id)) {
169
+ idMap.resolvePlaceholder(s.produces.placeholder, id, s.produces.aliases ?? []);
170
+ }
171
+ else {
172
+ ui.note(yellow('! ') + `${s.id} succeeded but produced no id at ${s.produces.path.join('.')} — later steps may skip`);
173
+ }
174
+ }
175
+ opts.onProgress?.(s, plan);
176
+ }
177
+ catch (e) {
178
+ s.status = 'failed';
179
+ s.error = e.message;
180
+ s.finishedAt = new Date().toISOString();
181
+ ui.update(s.id, { state: 'failed', detail: s.error });
182
+ await persist();
183
+ opts.onProgress?.(s, plan);
184
+ if (!opts.continueOnError)
185
+ break;
186
+ }
187
+ await persist();
188
+ }
189
+ const counts = plan.steps.reduce((acc, s) => {
190
+ acc[s.status] = (acc[s.status] ?? 0) + 1;
191
+ return acc;
192
+ }, {});
193
+ plan.status =
194
+ (counts['failed'] ?? 0) > 0
195
+ ? (counts['done'] ?? 0) > 0
196
+ ? 'partially-applied'
197
+ : 'failed'
198
+ : opts.dryRun
199
+ ? 'planned'
200
+ : 'applied';
201
+ await persist();
202
+ const done = counts['done'] ?? 0;
203
+ const failed = counts['failed'] ?? 0;
204
+ const skipped = counts['skipped'] ?? 0;
205
+ /**
206
+ * A dry run's steps are recorded as skipped, which is right for the plan file
207
+ * and wrong as a headline: "0 done, 2 skipped" reads like something went
208
+ * wrong. Nothing did — nothing was sent.
209
+ */
210
+ ui.stop(opts.dryRun
211
+ ? ' ' + green(`${plan.steps.length} step${plan.steps.length === 1 ? '' : 's'} simulated`) + dim(', nothing sent')
212
+ : ' ' +
213
+ [
214
+ done ? green(`${done} done`) : dim('0 done'),
215
+ failed ? red(`${failed} failed`) : dim('0 failed'),
216
+ skipped ? yellow(`${skipped} skipped`) : dim('0 skipped'),
217
+ ].join(dim(' · ')) +
218
+ dim(` · ${(ui.elapsed / 1000).toFixed(1)}s`));
219
+ return {
220
+ plan,
221
+ done: counts['done'] ?? 0,
222
+ failed: counts['failed'] ?? 0,
223
+ skipped: counts['skipped'] ?? 0,
224
+ pending: counts['pending'] ?? 0,
225
+ idMap,
226
+ };
227
+ };
228
+ const runInternalAutomationStep = async (internal, s, variables) => {
229
+ if (s.request.mutation === 'createAutomation') {
230
+ const created = await internal.createAutomation(variables);
231
+ if (!created)
232
+ throw new Error('createAutomation returned no automation (internal API unavailable or rejected)');
233
+ return { createAutomation: { automation: created } };
234
+ }
235
+ if (s.request.mutation === 'deleteAutomation') {
236
+ const success = await internal.deleteAutomation(String(variables['id']));
237
+ if (!success)
238
+ throw new Error(`deleteAutomation(${String(variables['id'])}) did not report success`);
239
+ return { deleteAutomation: { success } };
240
+ }
241
+ if (s.request.mutation === 'updateAutomation') {
242
+ const res = await internal.gql(`mutation updateAutomation($input: UpdateAutomationInput!) {
243
+ updateAutomation(input: $input) { automation { id } error_details { object_key messages } }
244
+ }`, { input: variables }, 'updateAutomation');
245
+ if (!res)
246
+ throw new Error('updateAutomation failed on the internal endpoint');
247
+ return res;
248
+ }
249
+ throw new Error(`unknown internal mutation ${s.request.mutation}`);
250
+ };
251
+ const runInternalRestStep = async (internal, s, request) => {
252
+ const phaseId = /\/phases\/([^/?]+)/.exec(request.path ?? '')?.[1];
253
+ if (!phaseId)
254
+ throw new Error(`could not read a phase id out of ${request.path}`);
255
+ const targets = (request.form?.['phase[jump_target_ids][]'] ?? []).filter((t) => t !== '');
256
+ /**
257
+ * A jump target is an id and nothing else.
258
+ *
259
+ * The endpoint answers a malformed target with a bare 404, which reads as
260
+ * "that phase does not exist" and sends you looking in the wrong place. Every
261
+ * such 404 so far has been a substitution that did not happen — a placeholder
262
+ * rewritten to `%{344123894}` rather than to `344123894`. Checking the shape
263
+ * here names the real cause.
264
+ */
265
+ const malformed = targets.filter((t) => !/^[0-9]+$/.test(t));
266
+ if (malformed.length) {
267
+ throw new Error(`jump target(s) ${malformed.join(', ')} are not ids — an id substitution did not happen. Not sent.`);
268
+ }
269
+ const res = await internal.setPhaseJumpTargets(phaseId, targets);
270
+ /**
271
+ * Assert on the echoed next_phase_ids, not on the HTTP status — PLAN.md §2.1.
272
+ * A 200 from an unversioned endpoint is not evidence that anything changed.
273
+ */
274
+ if (!res.ok)
275
+ throw new Error(res.error ?? 'phase jump write could not be confirmed from the response');
276
+ return res;
277
+ };
@@ -0,0 +1,293 @@
1
+ import { linkFlow } from "../codec/flow.js";
2
+ /** The node as an operation request: the whole node, minus link and volatile keys. */
3
+ const nodeRequest = (node) => {
4
+ const { _nextAction, _firstLoopAction, _children, ...rest } = node;
5
+ const out = {
6
+ name: rest['name'],
7
+ type: rest['type'],
8
+ valid: rest['valid'] ?? true,
9
+ settings: rest['settings'] ?? {},
10
+ };
11
+ if (rest['displayName'] !== undefined)
12
+ out['displayName'] = rest['displayName'];
13
+ if (rest['skip'] !== undefined)
14
+ out['skip'] = rest['skip'];
15
+ return out;
16
+ };
17
+ /**
18
+ * Where a new node hangs. `ADD_ACTION` takes the parent it follows, plus a
19
+ * location for the cases where "after" is ambiguous.
20
+ *
21
+ * A bare `{ parentStep }` with no location is only unambiguous when the
22
+ * parent is a plain step. When the parent is itself a `LOOP_ON_ITEMS`,
23
+ * "after it" could mean *inside* the loop or *exiting* it, and the endpoint
24
+ * refuses to guess: measured live (`research/ipaas/probe-loop-shape-3.ts`),
25
+ * the identical omitted-location request answers `Loop step parent undefined
26
+ * not found`, and adding `stepLocationRelativeToParent: 'AFTER'` is what
27
+ * makes the same add succeed. Every flow this tool wrote before had no loop
28
+ * at all, so this was never exercised.
29
+ */
30
+ const parentOf = (tree, nodeName) => {
31
+ for (const candidate of tree.nodes) {
32
+ if (candidate['_nextAction'] === nodeName) {
33
+ return { parentStep: candidate.name, ...(candidate['type'] === 'LOOP_ON_ITEMS' ? { location: 'AFTER' } : {}) };
34
+ }
35
+ if (candidate['_firstLoopAction'] === nodeName) {
36
+ return { parentStep: candidate.name, location: 'INSIDE_LOOP' };
37
+ }
38
+ const children = candidate['_children'] ?? [];
39
+ const at = children.indexOf(nodeName);
40
+ if (at >= 0)
41
+ return { parentStep: candidate.name, location: 'INSIDE_BRANCH', branchIndex: at };
42
+ }
43
+ return null;
44
+ };
45
+ /** Every `{{connections['x']}}` a flow references, with the piece that needs it. */
46
+ export const connectionsUsedBy = (tree) => {
47
+ const out = new Map();
48
+ const CONN = /connections\['([^']+)'\]/g;
49
+ for (const node of tree.nodes) {
50
+ const settings = (node['settings'] ?? {});
51
+ const piece = String(settings['pieceName'] ?? '');
52
+ const text = JSON.stringify(settings['input'] ?? {});
53
+ for (const m of text.matchAll(CONN)) {
54
+ const id = m[1];
55
+ if (!out.has(id))
56
+ out.set(id, piece);
57
+ }
58
+ }
59
+ return [...out].map(([externalId, pieceName]) => ({ externalId, pieceName }));
60
+ };
61
+ export const compileFlows = (cs, opts = {}) => {
62
+ const steps = [];
63
+ const blocked = [];
64
+ const warnings = [];
65
+ const emit = (partial) => {
66
+ steps.push({ id: `f${String(steps.length + 1).padStart(3, '0')}`, index: steps.length, status: 'pending', ...partial });
67
+ };
68
+ /**
69
+ * Order matters and is not the diff's order.
70
+ *
71
+ * Creates before the changes that reference them; node deletes before node
72
+ * adds, so a rename that is really a delete-plus-add does not collide on a
73
+ * name; and status last, because arming a flow should be the final thing that
74
+ * happens to it, after its contents are what they should be.
75
+ */
76
+ const order = {
77
+ 'flow.create': 0,
78
+ 'flow.rename': 1,
79
+ 'node.delete': 2,
80
+ 'trigger.update': 3,
81
+ 'node.create': 4,
82
+ 'node.update': 5,
83
+ 'flow.delete': 6,
84
+ 'flow.status': 7,
85
+ };
86
+ const sorted = [...cs.changes].sort((a, b) => order[a.op] - order[b.op]);
87
+ /**
88
+ * Connections first: a flow cannot be created referencing one the project does
89
+ * not have. A missing connection used to be a refusal, which stalled the whole
90
+ * build for something the flow structure does not depend on.
91
+ */
92
+ if (opts.connections) {
93
+ const needed = new Map();
94
+ for (const c of cs.changes) {
95
+ const tree = (c.afterFlow ?? null);
96
+ if (!tree)
97
+ continue;
98
+ for (const use of connectionsUsedBy(tree)) {
99
+ if (opts.connections.has(use.externalId))
100
+ continue;
101
+ if (!needed.has(use.externalId))
102
+ needed.set(use.externalId, use.pieceName);
103
+ }
104
+ }
105
+ for (const [externalId, pieceName] of needed) {
106
+ emit({
107
+ changeKey: `connection:${externalId}`,
108
+ flowId: null,
109
+ createConnection: {
110
+ pieceName,
111
+ externalId,
112
+ displayName: `${pieceName.split('/').pop()} (placeholder, reconnect me)`,
113
+ },
114
+ description: `create placeholder connection ${externalId} for ${pieceName}`,
115
+ });
116
+ warnings.push(`connection ${externalId} does not exist in this project and will be created with a ` +
117
+ 'placeholder credential — reconnect it in the product before enabling any flow that uses it');
118
+ }
119
+ }
120
+ for (const c of sorted) {
121
+ switch (c.op) {
122
+ case 'flow.create': {
123
+ const tree = c.afterFlow;
124
+ emit({
125
+ changeKey: c.key,
126
+ flowId: null,
127
+ createFlow: { displayName: c.flowName, tree },
128
+ description: `create flow "${c.flowName}" and add its ${tree.nodes.length} nodes`,
129
+ });
130
+ break;
131
+ }
132
+ case 'flow.delete': {
133
+ emit({
134
+ changeKey: c.key,
135
+ flowId: c.flowId,
136
+ deleteFlow: true,
137
+ description: `delete flow "${c.flowName}"`,
138
+ destructive: true,
139
+ });
140
+ break;
141
+ }
142
+ case 'flow.rename': {
143
+ emit({
144
+ changeKey: c.key,
145
+ flowId: c.flowId,
146
+ operation: { type: 'CHANGE_NAME', request: { displayName: c.flowName } },
147
+ description: `CHANGE_NAME — "${c.flowName}"`,
148
+ });
149
+ break;
150
+ }
151
+ case 'flow.status': {
152
+ const status = String(c.deltas[0]?.after ?? '');
153
+ if (status !== 'ENABLED' && status !== 'DISABLED') {
154
+ blocked.push({ key: c.key, label: c.label, reason: `status ${JSON.stringify(status)} is not ENABLED or DISABLED` });
155
+ break;
156
+ }
157
+ /**
158
+ * Enabling a flow points it at live traffic. It is refused unless asked
159
+ * for explicitly, the same way a phase delete needs --allow-card-moves:
160
+ * the cost of a wrong one is paid by the client, not by the person running
161
+ * the apply.
162
+ */
163
+ const usesPlaceholder = [...(opts.connections ?? new Map())].some(([, c]) => c['status'] === 'PLACEHOLDER');
164
+ if (status === 'ENABLED' && usesPlaceholder) {
165
+ blocked.push({
166
+ key: c.key,
167
+ label: c.label,
168
+ reason: 'this project has a placeholder connection — reconnect it before enabling a flow, ' +
169
+ 'or the flow will run and fail on every trigger',
170
+ });
171
+ break;
172
+ }
173
+ if (status === 'ENABLED' && !opts.allowArm) {
174
+ blocked.push({
175
+ key: c.key,
176
+ label: c.label,
177
+ reason: 'enabling a flow arms it against live traffic — re-run with --allow-flow-enable to do it deliberately',
178
+ });
179
+ break;
180
+ }
181
+ emit({
182
+ changeKey: c.key,
183
+ flowId: c.flowId,
184
+ operation: { type: 'CHANGE_STATUS', request: { status } },
185
+ description: `CHANGE_STATUS — ${status}`,
186
+ arms: status === 'ENABLED',
187
+ });
188
+ break;
189
+ }
190
+ case 'trigger.update': {
191
+ emit({
192
+ changeKey: c.key,
193
+ flowId: c.flowId,
194
+ operation: { type: 'UPDATE_TRIGGER', request: nodeRequest(c.after) },
195
+ description: `UPDATE_TRIGGER — flow "${c.flowName}"`,
196
+ });
197
+ break;
198
+ }
199
+ case 'node.update': {
200
+ emit({
201
+ changeKey: c.key,
202
+ flowId: c.flowId,
203
+ operation: { type: 'UPDATE_ACTION', request: nodeRequest(c.after) },
204
+ description: `UPDATE_ACTION — ${c.nodeName} in "${c.flowName}"`,
205
+ });
206
+ break;
207
+ }
208
+ case 'node.delete': {
209
+ emit({
210
+ changeKey: c.key,
211
+ flowId: c.flowId,
212
+ // `names` is an array — a single `name` is rejected.
213
+ operation: { type: 'DELETE_ACTION', request: { names: [c.nodeName] } },
214
+ description: `DELETE_ACTION — ${c.nodeName} in "${c.flowName}"`,
215
+ destructive: true,
216
+ });
217
+ break;
218
+ }
219
+ case 'node.create': {
220
+ const tree = c.afterFlow;
221
+ const where = tree ? parentOf(tree, c.nodeName) : null;
222
+ if (!where) {
223
+ // Unreachable from the chain — nothing points `_nextAction` /
224
+ // `_firstLoopAction` / a branch's `_children` at this node, so there is
225
+ // no parent to hang it from. The same case `buildFlowOperations` skips
226
+ // for a brand-new flow.
227
+ blocked.push({
228
+ key: c.key,
229
+ label: c.label,
230
+ reason: `${c.nodeName} is not reachable from the trigger — nothing in the authored flow ` +
231
+ 'points to it, so there is no parent to add it after.',
232
+ });
233
+ break;
234
+ }
235
+ emit({
236
+ changeKey: c.key,
237
+ flowId: c.flowId,
238
+ operation: {
239
+ type: 'ADD_ACTION',
240
+ request: {
241
+ parentStep: where.parentStep,
242
+ ...(where.location ? { stepLocationRelativeToParent: where.location } : {}),
243
+ ...(where.branchIndex !== undefined ? { branchIndex: where.branchIndex } : {}),
244
+ action: nodeRequest(c.after),
245
+ },
246
+ },
247
+ description: `ADD_ACTION — ${c.nodeName} [${c.after.type}] after ${where.parentStep}${where.location ? ` (${where.location})` : ''} in "${c.flowName}"`,
248
+ });
249
+ break;
250
+ }
251
+ }
252
+ }
253
+ if (blocked.length) {
254
+ warnings.push(`${blocked.length} flow change(s) have no write path yet`);
255
+ }
256
+ return { steps, blocked, warnings };
257
+ };
258
+ /**
259
+ * The operations that build a brand-new flow's contents.
260
+ *
261
+ * A created flow arrives with an `EMPTY` trigger and nothing else, so the tree is
262
+ * laid down trigger-first and then each node in execution order — every node's
263
+ * parent already exists by the time it is added, because execution order is a
264
+ * topological order of the chain.
265
+ */
266
+ export const buildFlowOperations = (tree) => {
267
+ const ops = [];
268
+ const trigger = tree.nodes[0];
269
+ if (!trigger)
270
+ return ops;
271
+ ops.push({ type: 'UPDATE_TRIGGER', request: nodeRequest(trigger), describe: `trigger [${trigger.type}]` });
272
+ for (const node of tree.nodes.slice(1)) {
273
+ const where = parentOf(tree, node.name);
274
+ if (!where) {
275
+ // Unreachable from the chain: the codec would have dropped it anyway, and
276
+ // adding it with a guessed parent would put it somewhere nobody asked for.
277
+ continue;
278
+ }
279
+ ops.push({
280
+ type: 'ADD_ACTION',
281
+ request: {
282
+ parentStep: where.parentStep,
283
+ ...(where.location ? { stepLocationRelativeToParent: where.location } : {}),
284
+ ...(where.branchIndex !== undefined ? { branchIndex: where.branchIndex } : {}),
285
+ action: nodeRequest(node),
286
+ },
287
+ describe: `${node.name} [${node.type}] after ${where.parentStep}${where.location ? ` (${where.location})` : ''}`,
288
+ });
289
+ }
290
+ return ops;
291
+ };
292
+ /** The API flow row a tree would produce, for verification after an apply. */
293
+ export const intendedFlow = (tree) => linkFlow(tree);