@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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 (162) 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 +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
@@ -0,0 +1,406 @@
1
+ import { ENDPOINTS } from "../config.js";
2
+ import { debug, warn } from "../util/log.js";
3
+ import { retry } from "../util/pool.js";
4
+ import { AI_BEHAVIOR_PARAMS_SELECTION } from "./agents.js";
5
+ /**
6
+ * The internal-API adapter. PLAN.md §2.1 flags this as an unversioned endpoint
7
+ * with no compatibility contract, so it is isolated here, behind one module,
8
+ * and every entry point degrades to `null`/`unsupported` rather than throwing.
9
+ *
10
+ * Two things live only here — both taken from the Change Migrator recipes,
11
+ * which have been running them in production:
12
+ *
13
+ * 1. Automations. Public `getAutomations` returns a summary; the recipes use
14
+ * `loadAutomationToEdit` to get `action_params.field_map`, `searchFor`,
15
+ * `responseSchema`, `schedulerCron` and `aiParams` — everything a
16
+ * round trip needs and the summary omits.
17
+ * 2. Phase jumps. `GET/PUT /internal_api/settings/phases/:id`, set-replacing.
18
+ *
19
+ * Both accept the same personal API token as the public endpoint, so there is
20
+ * no session-cookie or CSRF subsystem here.
21
+ */
22
+ /** Sent by `listAutomations`; module-level so `checkQueries` validates the same text. */
23
+ const LIST_AUTOMATIONS_QUERY = `
24
+ query getAutomations($orgId: ID!, $repoId: ID) {
25
+ automations(organizationId: $orgId, repoId: $repoId) {
26
+ id
27
+ name
28
+ action_id
29
+ event_id
30
+ created_at
31
+ active
32
+ action_repo: action_repo_v2 {
33
+ __typename
34
+ ... on Pipe { id }
35
+ ... on Table { id }
36
+ }
37
+ event_repo { __typename id }
38
+ }
39
+ }`;
40
+ /** Sent by `loadAutomation`; module-level so `checkQueries` validates the same text. */
41
+ const LOAD_AUTOMATION_QUERY = `
42
+ query loadAutomationToEdit($id: ID!) {
43
+ automation(id: $id) {
44
+ name
45
+ event_id
46
+ active
47
+ event_repo { id }
48
+ event_params {
49
+ toPhaseId: to_phase_id
50
+ fromPhaseId
51
+ inPhaseId
52
+ triggerFieldIds
53
+ kindOfSla
54
+ triggerAutomationId
55
+ }
56
+ action_id
57
+ action_repo: action_repo_v2 {
58
+ ... on Pipe { id uuid }
59
+ ... on Table { id uuid }
60
+ }
61
+ scheduler_frequency
62
+ schedulerCron { minute hour dayOfWeek dayOfMonth month }
63
+ searchFor { id field operation value }
64
+ responseSchema
65
+ action_params {
66
+ toPhaseId: to_phase_id
67
+ fieldsMapOrder: fields_map_order
68
+ email_template_id
69
+ fieldMap: field_map { fieldId value inputMode }
70
+ cardId: card_id
71
+ headers
72
+ body
73
+ url
74
+ httpMethod
75
+ authenticationKey
76
+ hasAuthenticationValue
77
+ authenticationAddTo
78
+ authenticationType
79
+ strategy
80
+ # taskParams is not in the recipe's query, and the omission is not
81
+ # cosmetic: every send_a_task automation came back with an empty
82
+ # action_params, so a reconstructed read lost its recipients and
83
+ # title, and a recreate would have rebuilt it blank. Introspected
84
+ # from AutomationActionParams on the internal endpoint.
85
+ taskParams { recipients title }
86
+ aiParams { fieldIds value skillsIds }
87
+ aiBehaviorParams ${AI_BEHAVIOR_PARAMS_SELECTION}
88
+ slaParams {
89
+ monday { startHour endHour enabled }
90
+ tuesday { startHour endHour enabled }
91
+ wednesday { startHour endHour enabled }
92
+ thursday { startHour endHour enabled }
93
+ friday { startHour endHour enabled }
94
+ saturday { startHour endHour enabled }
95
+ sunday { startHour endHour enabled }
96
+ timezone
97
+ holidays { date recurrence description }
98
+ }
99
+ }
100
+ condition {
101
+ expressions_structure
102
+ expressions { id field_address operation value structure_id }
103
+ }
104
+ }
105
+ }`;
106
+ /**
107
+ * Every read query document this adapter sends over GraphQL, with variables
108
+ * that match nothing. GraphQL validates the document before resolving any of
109
+ * it, so a query that has drifted from the schema is refused the same way
110
+ * whichever id it names — and a valid one answers `data` with a not-found
111
+ * error. No write is in this list, and none may be added: probing is not free
112
+ * on this platform (AGENTS.md §4).
113
+ */
114
+ const READ_QUERIES = [
115
+ { label: 'listAutomations', query: LIST_AUTOMATIONS_QUERY, variables: { orgId: '0', repoId: '0' } },
116
+ { label: 'loadAutomation', query: LOAD_AUTOMATION_QUERY, variables: { id: '0' } },
117
+ ];
118
+ export class InternalApi {
119
+ tokenProvider;
120
+ status = 'unknown';
121
+ requestCount = 0;
122
+ /** Set once, so a broken endpoint produces one warning and not one per call. */
123
+ warned = false;
124
+ constructor(token) {
125
+ this.tokenProvider = typeof token === 'string' ? async () => token : token;
126
+ }
127
+ async bearer() {
128
+ return this.tokenProvider();
129
+ }
130
+ markUnavailable(reason) {
131
+ if (this.status !== 'incompatible')
132
+ this.status = 'unavailable';
133
+ if (!this.warned) {
134
+ this.warned = true;
135
+ warn(`internal API unavailable (${reason}). Automation details and phase jumps ` +
136
+ `will be reported as unknown rather than empty — see PLAN.md §2.1.`);
137
+ }
138
+ }
139
+ /** One answered call does not clear a refused query: that stays visible. */
140
+ markOk() {
141
+ if (this.status !== 'incompatible')
142
+ this.status = 'ok';
143
+ }
144
+ markIncompatible(label, reason) {
145
+ this.status = 'incompatible';
146
+ if (!this.warned) {
147
+ this.warned = true;
148
+ warn(`internal API rejected this tool's ${label} query as invalid (${reason}). ` +
149
+ `That is a bug in pipe, not an outage — the endpoint changed under it. Automation ` +
150
+ `details will be reported as unknown rather than empty; \`pipe doctor\` checks every internal query.`);
151
+ }
152
+ }
153
+ /**
154
+ * Validate every read query against the live schema without reading anything
155
+ * real. Null when the endpoint itself could not be reached, which says
156
+ * nothing about the queries.
157
+ */
158
+ async checkQueries() {
159
+ const results = [];
160
+ for (const { label, query, variables } of READ_QUERIES) {
161
+ let body;
162
+ try {
163
+ this.requestCount++;
164
+ const res = await fetch(ENDPOINTS.internal, {
165
+ method: 'POST',
166
+ headers: {
167
+ authorization: `Bearer ${await this.bearer()}`,
168
+ 'content-type': 'application/json',
169
+ accept: 'application/json',
170
+ },
171
+ body: JSON.stringify({ query, variables }),
172
+ });
173
+ if (!res.ok) {
174
+ debug(`internal query check ${label}: HTTP ${res.status}`);
175
+ return null;
176
+ }
177
+ body = JSON.parse(await res.text());
178
+ }
179
+ catch (e) {
180
+ debug(`internal query check ${label}: ${e.message}`);
181
+ return null;
182
+ }
183
+ if ('data' in body)
184
+ results.push({ label, ok: true });
185
+ else
186
+ results.push({ label, ok: false, message: (body.errors ?? []).map((e) => e.message).join('; ') || 'no data' });
187
+ }
188
+ this.status = results.every((r) => r.ok) ? 'ok' : 'incompatible';
189
+ return results;
190
+ }
191
+ /** GraphQL over the internal endpoint. Returns null when the endpoint fails. */
192
+ async gql(query, variables, label) {
193
+ try {
194
+ const data = await retry(async () => {
195
+ this.requestCount++;
196
+ const res = await fetch(ENDPOINTS.internal, {
197
+ method: 'POST',
198
+ headers: {
199
+ authorization: `Bearer ${await this.bearer()}`,
200
+ 'content-type': 'application/json',
201
+ accept: 'application/json',
202
+ },
203
+ body: JSON.stringify({ query, variables }),
204
+ });
205
+ const text = await res.text();
206
+ if (!res.ok) {
207
+ const err = new Error(`HTTP ${res.status}: ${text.slice(0, 160)}`);
208
+ err.retryable = res.status >= 500 || res.status === 429;
209
+ throw err;
210
+ }
211
+ const body = JSON.parse(text);
212
+ if (body.errors?.length) {
213
+ const err = new Error(body.errors.map((e) => e.message).join('; '));
214
+ err.retryable = false;
215
+ // No `data` key at all is a document the schema refused, not a
216
+ // resolver failing on this one id — see InternalStatus.
217
+ err.invalidQuery = !('data' in body);
218
+ throw err;
219
+ }
220
+ return body.data ?? null;
221
+ }, { label });
222
+ if (data)
223
+ this.markOk();
224
+ return data;
225
+ }
226
+ catch (e) {
227
+ debug(`internal gql ${label} failed: ${e.message}`);
228
+ if (e.invalidQuery)
229
+ this.markIncompatible(label, e.message);
230
+ else
231
+ this.markUnavailable(`${label}: ${e.message}`);
232
+ return null;
233
+ }
234
+ }
235
+ // ── Automations ────────────────────────────────────────────────────────────
236
+ /** Recipe `Pipe Data Treatment` step 156. */
237
+ async listAutomations(orgId, repoId) {
238
+ const data = await this.gql(LIST_AUTOMATIONS_QUERY, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
239
+ return data?.automations ?? null;
240
+ }
241
+ /**
242
+ * Recipe `Apply Changes` step 118 — the full editable definition. This is the
243
+ * only read path that returns `action_params.field_map`, `searchFor`,
244
+ * `responseSchema` and `aiParams`, which is why the reconstruction path
245
+ * cannot avoid the internal API for automations.
246
+ */
247
+ async loadAutomation(id) {
248
+ const data = await this.gql(LOAD_AUTOMATION_QUERY, { id: String(id) }, `loadAutomation ${id}`);
249
+ return data?.automation ?? null;
250
+ }
251
+ /** Recipe `Apply Changes` step 121. `input` is the AutomationInput variable set. */
252
+ async createAutomation(vars) {
253
+ const q = `
254
+ mutation createAutomation(
255
+ $name: String!, $action_id: ID!, $event_id: ID!,
256
+ $action_repo_id: ID, $event_repo_id: ID,
257
+ $event_params: AutomationEventParamsInput,
258
+ $action_params: AutomationActionParamsInput,
259
+ $condition: ConditionInput,
260
+ $scheduler_frequency: String,
261
+ $searchFor: [SearchConditionInput],
262
+ $schedulerCron: CronInput,
263
+ $responseSchema: JSON
264
+ ) {
265
+ createAutomation(input: {
266
+ name: $name, action_id: $action_id, event_id: $event_id,
267
+ action_repo_id: $action_repo_id, event_repo_id: $event_repo_id,
268
+ event_params: $event_params, action_params: $action_params,
269
+ condition: $condition, scheduler_frequency: $scheduler_frequency,
270
+ searchFor: $searchFor, schedulerCron: $schedulerCron,
271
+ responseSchema: $responseSchema
272
+ }) {
273
+ automation { id }
274
+ error_details { object_key messages }
275
+ }
276
+ }`;
277
+ const data = await this.gql(q, vars, 'createAutomation');
278
+ const payload = data?.createAutomation;
279
+ if (!payload)
280
+ return null;
281
+ if (payload.error_details?.length) {
282
+ const detail = payload.error_details.map((d) => `${d.object_key}: ${d.messages.join(', ')}`).join(' | ');
283
+ throw new Error(`createAutomation rejected — ${detail}`);
284
+ }
285
+ return payload.automation;
286
+ }
287
+ /** Recipe `Apply Changes` step 126. */
288
+ async deleteAutomation(id) {
289
+ const q = `
290
+ mutation deleteAutomation($id: ID!) {
291
+ deleteAutomation(input: { id: $id }) { success }
292
+ }`;
293
+ const data = await this.gql(q, { id: String(id) }, `deleteAutomation ${id}`);
294
+ return Boolean(data?.deleteAutomation?.success);
295
+ }
296
+ // ── Phase jumps ────────────────────────────────────────────────────────────
297
+ /**
298
+ * `GET /internal_api/settings/phases/:id` — recipe step 23.
299
+ *
300
+ * **The GET and the PUT answer in different shapes.** Both captured live:
301
+ *
302
+ * GET { data: { attributes: { name, phases: [[id, name, done], …],
303
+ * jump_targets: [344054275] } } }
304
+ * PUT { data: { name, back_phase_ids: [], next_phase_ids: [344054275] } }
305
+ *
306
+ * The GET's `jump_targets` is one merged set, which is exactly what the
307
+ * payload's `phase_jumps` array holds. Reading it as `data.next_phase_ids` —
308
+ * the shape PLAN.md §2.1 recorded, from the PUT — yields an empty set for
309
+ * every phase without any error, and a diff that then proposes deleting every
310
+ * jump in the pipe.
311
+ */
312
+ async getPhaseSettings(phaseId) {
313
+ try {
314
+ const res = await retry(async () => {
315
+ this.requestCount++;
316
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
317
+ method: 'GET',
318
+ headers: { authorization: `Bearer ${await this.bearer()}`, accept: 'application/json' },
319
+ });
320
+ if (!r.ok) {
321
+ const err = new Error(`HTTP ${r.status}`);
322
+ err.retryable = r.status >= 500 || r.status === 429;
323
+ throw err;
324
+ }
325
+ return (await r.json());
326
+ }, { label: `getPhaseSettings ${phaseId}` });
327
+ const d = res.data ?? {};
328
+ const attrs = d.attributes ?? {};
329
+ const targets = attrs.jump_targets ?? d.jump_targets ?? [...(d.next_phase_ids ?? []), ...(d.back_phase_ids ?? [])];
330
+ this.markOk();
331
+ return {
332
+ jump_targets: [...new Set(targets.map(Number).filter((n) => Number.isFinite(n)))],
333
+ phases: (attrs.phases ?? []).map(([id, name, done]) => ({ id: Number(id), name: String(name), done: Boolean(done) })),
334
+ };
335
+ }
336
+ catch (e) {
337
+ debug(`getPhaseSettings ${phaseId} failed: ${e.message}`);
338
+ this.markUnavailable(`getPhaseSettings: ${e.message}`);
339
+ return null;
340
+ }
341
+ }
342
+ /**
343
+ * `PUT /internal_api/settings/phases/:id` with the complete desired jump set.
344
+ * Recipe step 24 does the same thing in Python. Set-replacing, so we send the
345
+ * whole set and assert on the echoed `next_phase_ids` rather than on the HTTP
346
+ * status — PLAN.md §2.1.
347
+ */
348
+ async setPhaseJumpTargets(phaseId, targetPhaseIds) {
349
+ const params = new URLSearchParams();
350
+ if (targetPhaseIds.length === 0) {
351
+ // An empty set still has to be expressible, or "remove the last jump"
352
+ // becomes impossible. The form convention for an empty array is a single
353
+ // blank entry.
354
+ params.append('phase[jump_target_ids][]', '');
355
+ }
356
+ for (const t of targetPhaseIds)
357
+ params.append('phase[jump_target_ids][]', String(t));
358
+ try {
359
+ const res = await retry(async () => {
360
+ this.requestCount++;
361
+ const r = await fetch(`${ENDPOINTS.internalSettings}/phases/${phaseId}`, {
362
+ method: 'PUT',
363
+ headers: {
364
+ authorization: `Bearer ${await this.bearer()}`,
365
+ 'content-type': 'application/x-www-form-urlencoded',
366
+ accept: 'application/json',
367
+ },
368
+ body: params.toString(),
369
+ });
370
+ const text = await r.text();
371
+ if (!r.ok) {
372
+ const err = new Error(`HTTP ${r.status}: ${text.slice(0, 160)}`);
373
+ err.retryable = r.status >= 500 || r.status === 429;
374
+ throw err;
375
+ }
376
+ return JSON.parse(text);
377
+ }, { label: `setPhaseJumpTargets ${phaseId}` });
378
+ /**
379
+ * The echo is the union of both directions.
380
+ *
381
+ * `next_phase_ids` alone is forward-only, so a jump to an *earlier* phase
382
+ * echoes back as an empty next list even though the write succeeded — which
383
+ * this assertion reported as a failed write. Asserting on the union is what
384
+ * the sent set actually means.
385
+ */
386
+ const d = res.data ?? {};
387
+ const echoed = [
388
+ ...(d.attributes?.jump_targets ?? []),
389
+ ...(d.jump_targets ?? []),
390
+ ...(d.next_phase_ids ?? []),
391
+ ...(d.back_phase_ids ?? []),
392
+ ].map(Number);
393
+ const want = [...new Set(targetPhaseIds.map(Number))].sort((a, b) => a - b);
394
+ const got = [...new Set(echoed)].sort((a, b) => a - b);
395
+ const ok = want.length === got.length && want.every((v, i) => v === got[i]);
396
+ this.markOk();
397
+ return ok
398
+ ? { ok, echoed }
399
+ : { ok, echoed, error: `echo mismatch: wanted [${want.join(',')}], endpoint reports [${got.join(',')}]` };
400
+ }
401
+ catch (e) {
402
+ this.markUnavailable(`setPhaseJumpTargets: ${e.message}`);
403
+ return { ok: false, echoed: [], error: e.message };
404
+ }
405
+ }
406
+ }
@@ -0,0 +1,269 @@
1
+ import { debug } from "../util/log.js";
2
+ /**
3
+ * A pipe's iPaaS workspace — "Advanced Automations", Activepieces underneath.
4
+ *
5
+ * Every pipe has its own workspace, and the session proves it: the identity that
6
+ * comes back has the pipe's name as `firstName` and its id as `lastName`. Flows
7
+ * and connections are scoped to it, so a flow belongs to exactly one pipe even
8
+ * though it may orchestrate across many.
9
+ *
10
+ * Authentication needs no new credential. Two hops from the token `pipe` already
11
+ * holds:
12
+ *
13
+ * 1. the INTERNAL API mints a short-lived, pipe-scoped JWT
14
+ * (`advancedAutomationsToken(repoId:)`)
15
+ * 2. `POST /managed-authn/external-token` exchanges it for a session — which
16
+ * also hands back the projectId, so nothing has to be configured
17
+ *
18
+ * The OAuth + PKCE dance in Pipefy's own gateway exists only to reach the iPaaS
19
+ * MCP tool surface. The REST API used here answers the step-2 session directly.
20
+ */
21
+ export const IPAAS_BASE = 'https://ipaas.pipefy.com/api/v1';
22
+ /**
23
+ * Operations that act on an empty request instead of rejecting it.
24
+ *
25
+ * Learned the hard way: probing the enum by sending every candidate with
26
+ * `request: {}` published and enabled a draft flow, because these four do not
27
+ * validate a request they can default. Nothing in this module may send one
28
+ * without an explicit, complete request.
29
+ */
30
+ export const ACTS_ON_EMPTY_REQUEST = new Set([
31
+ 'CHANGE_STATUS',
32
+ 'LOCK_AND_PUBLISH',
33
+ 'LOCK_FLOW',
34
+ 'CHANGE_FOLDER',
35
+ 'UPDATE_METADATA',
36
+ ]);
37
+ export class IpaasError extends Error {
38
+ status;
39
+ step;
40
+ constructor(step, status, body) {
41
+ super(`iPaaS ${step} failed (HTTP ${status}): ${body.slice(0, 300)}`);
42
+ this.name = 'IpaasError';
43
+ this.status = status;
44
+ this.step = step;
45
+ }
46
+ }
47
+ /** iPaaS is off for the org, or the caller cannot administer this pipe's automations. */
48
+ export class IpaasUnavailable extends Error {
49
+ pipeId;
50
+ constructor(pipeId, reason) {
51
+ super(reason);
52
+ this.name = 'IpaasUnavailable';
53
+ this.pipeId = pipeId;
54
+ }
55
+ }
56
+ export class IpaasClient {
57
+ internal;
58
+ base;
59
+ /** pipeId → session, because minting is two round trips and a pull does many reads. */
60
+ sessions = new Map();
61
+ /** A session lives a while, but not forever; re-mint rather than discover expiry mid-apply. */
62
+ static TTL_MS = 10 * 60 * 1000;
63
+ constructor(internal, base = IPAAS_BASE) {
64
+ this.internal = internal;
65
+ this.base = base.replace(/\/$/, '');
66
+ }
67
+ /**
68
+ * Whether this pipe has a reachable iPaaS workspace.
69
+ *
70
+ * A refusal here is `unknown` coverage, never "no flows" — the distinction the
71
+ * whole coverage doctrine rests on. Minting requires iPaaS enabled on the
72
+ * organization and permission to create automations on the pipe.
73
+ */
74
+ async available(pipeId) {
75
+ try {
76
+ const session = await this.session(pipeId);
77
+ return { ok: true, session };
78
+ }
79
+ catch (e) {
80
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
81
+ }
82
+ }
83
+ async session(pipeId) {
84
+ const hit = this.sessions.get(pipeId);
85
+ if (hit && Date.now() - hit.at < IpaasClient.TTL_MS)
86
+ return hit.session;
87
+ const minted = await this.internal.gql(`query GetAdvancedAutomationsToken($repoId: ID!) {
88
+ advancedAutomationsToken(repoId: $repoId) { token }
89
+ }`, { repoId: String(pipeId) }, 'GetAdvancedAutomationsToken');
90
+ const token = minted?.advancedAutomationsToken?.token;
91
+ if (!token) {
92
+ throw new IpaasUnavailable(pipeId, `no advanced-automations token for pipe ${pipeId} — iPaaS may be off for this organization, ` +
93
+ 'or this credential may not be allowed to create automations on the pipe');
94
+ }
95
+ const res = await fetch(`${this.base}/managed-authn/external-token`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json' },
98
+ body: JSON.stringify({ externalAccessToken: token }),
99
+ });
100
+ const body = await res.text();
101
+ if (!res.ok)
102
+ throw new IpaasError('session exchange', res.status, body);
103
+ const parsed = JSON.parse(body);
104
+ if (!parsed.token || !parsed.projectId) {
105
+ throw new IpaasError('session exchange', res.status, 'the response carried no token or projectId');
106
+ }
107
+ const session = { pipeId, projectId: parsed.projectId, userId: parsed.id };
108
+ this.sessions.set(pipeId, { session, token: parsed.token, at: Date.now() });
109
+ debug(`ipaas session for pipe ${pipeId}: project ${parsed.projectId}`);
110
+ return session;
111
+ }
112
+ async token(pipeId) {
113
+ await this.session(pipeId);
114
+ return this.sessions.get(pipeId).token;
115
+ }
116
+ async request(pipeId, method, path, body) {
117
+ const token = await this.token(pipeId);
118
+ const res = await fetch(`${this.base}/${path.replace(/^\//, '')}`, {
119
+ method,
120
+ // Content-Type only with a body: a bodyless DELETE carrying it is rejected
121
+ // outright with FST_ERR_CTP_EMPTY_JSON_BODY, so deleting a flow used to fail
122
+ // on a header rather than on anything to do with the flow.
123
+ headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
124
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
125
+ });
126
+ const text = await res.text();
127
+ if (res.status === 401) {
128
+ // The session aged out mid-run; mint once more before giving up.
129
+ this.sessions.delete(pipeId);
130
+ const retryToken = await this.token(pipeId);
131
+ const again = await fetch(`${this.base}/${path.replace(/^\//, '')}`, {
132
+ method,
133
+ headers: { Authorization: `Bearer ${retryToken}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
134
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
135
+ });
136
+ const againText = await again.text();
137
+ if (!again.ok)
138
+ throw new IpaasError(`${method} ${path}`, again.status, againText);
139
+ return (againText ? JSON.parse(againText) : null);
140
+ }
141
+ if (!res.ok)
142
+ throw new IpaasError(`${method} ${path}`, res.status, text);
143
+ return (text ? JSON.parse(text) : null);
144
+ }
145
+ // ── reads ─────────────────────────────────────────────────────────────────
146
+ async listFlows(pipeId) {
147
+ const { projectId } = await this.session(pipeId);
148
+ const out = await this.request(pipeId, 'GET', `flows?projectId=${projectId}`);
149
+ return out?.data ?? [];
150
+ }
151
+ async getFlow(pipeId, flowId) {
152
+ return this.request(pipeId, 'GET', `flows/${flowId}`);
153
+ }
154
+ async listConnections(pipeId) {
155
+ const { projectId } = await this.session(pipeId);
156
+ const out = await this.request(pipeId, 'GET', `app-connections?projectId=${projectId}`);
157
+ // Only the four columns a reference needs. A credential is never one of them.
158
+ return (out?.data ?? []).map((c) => ({
159
+ externalId: String(c['externalId'] ?? ''),
160
+ pieceName: String(c['pieceName'] ?? ''),
161
+ displayName: String(c['displayName'] ?? ''),
162
+ status: String(c['status'] ?? ''),
163
+ }));
164
+ }
165
+ async listRuns(pipeId) {
166
+ const { projectId } = await this.session(pipeId);
167
+ const out = await this.request(pipeId, 'GET', `flow-runs?projectId=${projectId}`);
168
+ return (out?.data ?? []).map((r) => ({
169
+ id: String(r['id'] ?? ''),
170
+ flowId: String(r['flowId'] ?? ''),
171
+ status: String(r['status'] ?? ''),
172
+ startTime: String(r['startTime'] ?? ''),
173
+ }));
174
+ }
175
+ /** pieceName → the version this instance offers. Instance-wide, not per project. */
176
+ async listPieces(pipeId) {
177
+ const raw = await this.request(pipeId, 'GET', 'pieces');
178
+ return Object.fromEntries((raw ?? []).filter((p) => p.name && p.version).map((p) => [p.name, p.version]));
179
+ }
180
+ /** One piece's action and trigger names, for checking a node against the catalogue. */
181
+ async pieceMembers(pipeId, pieceName) {
182
+ const meta = await this.request(pipeId, 'GET', `pieces/${encodeURIComponent(pieceName)}`);
183
+ return { actions: Object.keys(meta?.actions ?? {}), triggers: Object.keys(meta?.triggers ?? {}) };
184
+ }
185
+ // ── writes ────────────────────────────────────────────────────────────────
186
+ /**
187
+ * Create a connection with a placeholder credential.
188
+ *
189
+ * A pipe whose iPaaS workspace has no connections cannot host a flow, and the
190
+ * credential is not obtainable here: `piece-pipefy` authenticates by
191
+ * `client_credentials` against Pipefy's own OAuth token endpoint, and this
192
+ * host has no OAuth client registered for it — only for Slack. Connections are
193
+ * also hard-isolated per project: the update endpoint accepts `scope` and
194
+ * `projectIds` and returns 200 while ignoring both, and a different project's
195
+ * session gets 403 on the connection itself.
196
+ *
197
+ * So a placeholder is created instead, which is how Pipefy's own import path
198
+ * behaves — import, then remap the connection in the UI. The flow structure is
199
+ * what is being built and it does not depend on the credential working.
200
+ *
201
+ * **The host does not validate the value.** It answers 201 and marks the
202
+ * connection ACTIVE whatever is sent, so `status: ACTIVE` is not evidence that
203
+ * a connection works. Everything this tool creates is tagged `_placeholder` so
204
+ * a flow that depends on one can be reported as unreconnected, and a flow with
205
+ * a placeholder connection is never enabled.
206
+ */
207
+ async createPlaceholderConnection(pipeId, spec) {
208
+ const { projectId } = await this.session(pipeId);
209
+ const externalId = spec.externalId ?? `pipe-placeholder-${spec.pieceName.split('/').pop()}`;
210
+ const type = 'CUSTOM_AUTH';
211
+ const out = await this.request(pipeId, 'POST', 'app-connections', {
212
+ externalId,
213
+ displayName: spec.displayName,
214
+ pieceName: spec.pieceName,
215
+ projectId,
216
+ // The discriminator goes in both positions; the host wants it twice.
217
+ type,
218
+ value: {
219
+ type,
220
+ props: {
221
+ // Named so anyone reading the connection in the product knows what it is.
222
+ placeholder: 'created by pipe — reconnect this before enabling any flow',
223
+ },
224
+ },
225
+ });
226
+ return {
227
+ externalId: String(out?.['externalId'] ?? externalId),
228
+ pieceName: spec.pieceName,
229
+ displayName: spec.displayName,
230
+ status: String(out?.['status'] ?? 'UNKNOWN'),
231
+ _placeholder: true,
232
+ };
233
+ }
234
+ async deleteConnection(pipeId, connectionId) {
235
+ await this.request(pipeId, 'DELETE', `app-connections/${connectionId}`);
236
+ }
237
+ async createFlow(pipeId, displayName) {
238
+ const { projectId } = await this.session(pipeId);
239
+ const out = await this.request(pipeId, 'POST', 'flows', { projectId, displayName });
240
+ if (!out?.id)
241
+ throw new IpaasError('POST flows', 200, 'the response carried no flow id');
242
+ return out.id;
243
+ }
244
+ async deleteFlow(pipeId, flowId) {
245
+ await this.request(pipeId, 'DELETE', `flows/${flowId}`);
246
+ }
247
+ /**
248
+ * One flow operation.
249
+ *
250
+ * `IMPORT_FLOW` is refused here on purpose. It replaces the whole tree *and*
251
+ * runs the schema migration an old export needs, so a flow already in live form
252
+ * has every datapill rewritten — `{{step_1['output'].x}}` becomes
253
+ * `{{step_1['output']['output'].x}}` — and then resolves nothing at runtime,
254
+ * while every validator still calls it valid. The granular operations do not
255
+ * migrate. If an actual export ever needs importing, that is a separate path
256
+ * with its own conversion.
257
+ */
258
+ async operate(pipeId, flowId, type, request) {
259
+ if (type === 'IMPORT_FLOW') {
260
+ throw new Error('IMPORT_FLOW is not used by this tool: it migrates the tree it is given, which silently ' +
261
+ 'doubles every datapill of a flow already in live form. Use the granular operations.');
262
+ }
263
+ if (ACTS_ON_EMPTY_REQUEST.has(type) && Object.keys(request).length === 0) {
264
+ throw new Error(`${type} acts on an empty request rather than rejecting it — sending one would take a ` +
265
+ 'default nobody asked for. Supply the request explicitly.');
266
+ }
267
+ return this.request(pipeId, 'POST', `flows/${flowId}`, { type, request });
268
+ }
269
+ }