@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.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,414 @@
1
+ import { dim, info, warn } from '../util/log.ts';
2
+ import { Progress } from '../util/tui.ts';
3
+ import { assertFlowRoundTrip, unlinkFlow } from '../codec/flow.ts';
4
+ import {
5
+ readFlows,
6
+ writeFlows,
7
+ writeFlowBaseline,
8
+ readFlowBaseline,
9
+ unknownFlows,
10
+ flowIdOf,
11
+ type FlowsSection,
12
+ } from '../workspace/flows.ts';
13
+ import { diffFlows, type FlowChangeSet } from '../diff/flow.ts';
14
+ import { buildFlowOperations, compileFlows, type FlowPlan, type FlowStep } from '../apply/flowops.ts';
15
+ import { IpaasClient, IpaasUnavailable, type ConnectionRow } from '../pipefy/ipaas.ts';
16
+
17
+ /**
18
+ * The flow half of the pipe loop: read on pull, diff, compile, apply.
19
+ *
20
+ * Kept in one module because the flow path is small and shares nothing with the
21
+ * pipe mutation compiler — the operations are per node against a different
22
+ * endpoint, and pretending otherwise would force both through a registry that
23
+ * describes GraphQL mutations.
24
+ */
25
+
26
+ /** Read a pipe's whole iPaaS workspace, or record why it could not be read. */
27
+ export const pullFlowsFor = async (
28
+ client: IpaasClient,
29
+ pipeId: number,
30
+ opts: { skipRoundTrip?: boolean } = {},
31
+ ): Promise<FlowsSection> => {
32
+ const probe = await client.available(pipeId);
33
+ if (!probe.ok) {
34
+ /**
35
+ * Not an error. iPaaS is gated per organization and per pipe, so "no token"
36
+ * is the normal answer for most pipes — and it must never be read as "this
37
+ * pipe has no flows", which is what would license deleting them later.
38
+ */
39
+ return unknownFlows(probe.reason);
40
+ }
41
+
42
+ const { projectId } = probe.session;
43
+ const rows = await client.listFlows(pipeId);
44
+ const flows = [];
45
+ const notes: string[] = [];
46
+
47
+ for (const row of rows) {
48
+ const full = await client.getFlow(pipeId, String(row['id']));
49
+ if (!opts.skipRoundTrip) {
50
+ const rt = assertFlowRoundTrip(full);
51
+ if (!rt.ok) {
52
+ // The fold is not lossless for this flow, so the split files would not
53
+ // rebuild it. Reported, and the raw row is what the diff will use.
54
+ notes.push(
55
+ `flow ${row['id']}: codec round trip is not clean (${rt.differences
56
+ .map((d) => `${d.where}: ${d.detail}`)
57
+ .join('; ')})`,
58
+ );
59
+ }
60
+ }
61
+ flows.push(unlinkFlow(full));
62
+ }
63
+
64
+ const [connections, pieces, runs] = await Promise.all([
65
+ client.listConnections(pipeId),
66
+ client.listPieces(pipeId),
67
+ client.listRuns(pipeId),
68
+ ]);
69
+
70
+ return {
71
+ meta: {
72
+ coverage: 'complete',
73
+ projectId,
74
+ readAt: new Date().toISOString(),
75
+ flows: flows.length,
76
+ connections: connections.length,
77
+ pieces: Object.keys(pieces).length,
78
+ notes,
79
+ },
80
+ flows,
81
+ connections,
82
+ pieces,
83
+ runs,
84
+ };
85
+ };
86
+
87
+ /** Pull and write, with a line per flow. Returns what was read. */
88
+ export const pullAndWriteFlows = async (
89
+ client: IpaasClient,
90
+ pipeId: number,
91
+ repoDir: string,
92
+ opts: { skipRoundTrip?: boolean; workspaceRoot?: string } = {},
93
+ ): Promise<FlowsSection> => {
94
+ const section = await pullFlowsFor(client, pipeId, opts);
95
+ await writeFlows(repoDir, section);
96
+ /**
97
+ * And record it as the baseline.
98
+ *
99
+ * Without this the flow diff had no past to compare against and re-read the
100
+ * pipe live on every invocation — so there was no offline diff, and no record
101
+ * of what a pull actually brought back. The pipe half has stored its baseline
102
+ * since the first version; this is the flow half catching up.
103
+ */
104
+ if (opts.workspaceRoot) await writeFlowBaseline(opts.workspaceRoot, pipeId, section);
105
+ return section;
106
+ };
107
+
108
+ /**
109
+ * The flows diff: what is on disk against what was recorded at the last pull.
110
+ *
111
+ * Mirrors the pipe half — `baseline` needs no network, `live` re-reads the pipe.
112
+ * The default is the baseline, so `pipe diff` stays offline and answers the same
113
+ * question for both halves of a process: what did *I* change.
114
+ *
115
+ * `live` is what an apply uses, because what matters there is the current state
116
+ * of the pipe, not the state someone pulled an hour ago.
117
+ */
118
+ export const diffFlowsFor = async (
119
+ client: IpaasClient | null,
120
+ pipeId: number,
121
+ repoDir: string,
122
+ opts: { against?: 'baseline' | 'live'; workspaceRoot: string },
123
+ ): Promise<{ changeSet: FlowChangeSet; before: FlowsSection | null; authored: FlowsSection | null }> => {
124
+ const authored = await readFlows(repoDir);
125
+ if ((opts.against ?? 'baseline') === 'baseline') {
126
+ const before = await readFlowBaseline(opts.workspaceRoot, pipeId);
127
+ if (!before) {
128
+ const reason =
129
+ 'no flow baseline for this repo — re-pull to record one, or use --against live';
130
+ return { changeSet: { changes: [], skipped: [{ reason }] }, before: null, authored };
131
+ }
132
+ return { changeSet: diffFlows(before, authored), before, authored };
133
+ }
134
+ if (!client) {
135
+ const reason = 'a live flow diff needs the network, and no client was supplied';
136
+ return { changeSet: { changes: [], skipped: [{ reason }] }, before: null, authored };
137
+ }
138
+ const live = await pullFlowsFor(client, pipeId, { skipRoundTrip: true });
139
+ return { changeSet: diffFlows(live, authored), before: live, authored };
140
+ };
141
+
142
+ /** The flows diff against the live pipe. What an apply compiles from. */
143
+ export const diffFlowsAgainstLive = async (
144
+ client: IpaasClient,
145
+ pipeId: number,
146
+ repoDir: string,
147
+ ): Promise<{ changeSet: FlowChangeSet; live: FlowsSection; authored: FlowsSection | null }> => {
148
+ const authored = await readFlows(repoDir);
149
+ const live = await pullFlowsFor(client, pipeId, { skipRoundTrip: true });
150
+ return { changeSet: diffFlows(live, authored), live, authored };
151
+ };
152
+
153
+ /**
154
+ * The revert plan, given both sides already read — pure, no network, so it
155
+ * is the part `test/flows-revert.test.ts` can actually exercise without a
156
+ * live `IpaasClient`.
157
+ *
158
+ * Direction is the mirror of an ordinary apply: `live` is the *current*
159
+ * state (baseline), `before` is the old pre-apply one (intended target). A
160
+ * node the failed apply added and `before` never had comes out as a delete;
161
+ * one it removed comes out as a create — the same diff engine, just pointed
162
+ * backwards.
163
+ */
164
+ export const buildFlowRevertPlan = (live: FlowsSection, before: FlowsSection): FlowPlan => {
165
+ const changeSet = diffFlows(live, before);
166
+ /**
167
+ * Allowed unconditionally, unlike an ordinary apply's --allow-flow-enable
168
+ * gate: putting a flow back to enabled is restoring what was already true
169
+ * before this apply touched it, not arming something new. The
170
+ * confirmation prompt renders the plan either way, so an enable is never
171
+ * silent.
172
+ */
173
+ return planFlows(changeSet, { allowArm: true, connections: live.connections });
174
+ };
175
+
176
+ /**
177
+ * What reverting to a pre-apply flow state would take — computed, not sent.
178
+ *
179
+ * `before` is the live read `diffFlowsAgainstLive` already took right
180
+ * before this apply's own diff was computed — the only rollback point flows
181
+ * have at all, since Pipefy's snapshot restore never covered them (it is
182
+ * pipe structure only; see AGENTS.md). `live` here is read fresh, because
183
+ * some steps may have landed since `before` was taken.
184
+ */
185
+ export const planFlowRevert = async (
186
+ client: IpaasClient,
187
+ pipeId: number,
188
+ before: FlowsSection,
189
+ ): Promise<{ plan: FlowPlan; live: FlowsSection }> => {
190
+ const live = await pullFlowsFor(client, pipeId, { skipRoundTrip: true });
191
+ return { plan: buildFlowRevertPlan(live, before), live };
192
+ };
193
+
194
+ export const planFlows = (
195
+ cs: FlowChangeSet,
196
+ opts: { allowArm?: boolean; connections?: ConnectionRow[] } = {},
197
+ ): FlowPlan =>
198
+ compileFlows(cs, {
199
+ allowArm: opts.allowArm,
200
+ connections: new Map(
201
+ (opts.connections ?? []).map((c) => [
202
+ c.externalId,
203
+ // A placeholder is reported as its own status so the compiler can refuse to
204
+ // arm a flow that depends on one.
205
+ { pieceName: c.pieceName, status: c._placeholder ? 'PLACEHOLDER' : c.status },
206
+ ]),
207
+ ),
208
+ });
209
+
210
+ export const renderFlowPlan = (plan: FlowPlan): string => {
211
+ const lines: string[] = [];
212
+ if (plan.blocked.length) {
213
+ lines.push('');
214
+ lines.push(` ${plan.blocked.length} flow change(s) cannot be applied:`);
215
+ for (const b of plan.blocked) {
216
+ lines.push(` x ${b.label}`);
217
+ lines.push(` ${b.reason}`);
218
+ }
219
+ }
220
+ if (!plan.steps.length) {
221
+ if (!plan.blocked.length) lines.push(' flows: nothing to apply');
222
+ return lines.join('\n');
223
+ }
224
+ lines.push('');
225
+ lines.push(` ${plan.steps.length} flow step(s)`);
226
+ for (const s of plan.steps) {
227
+ const tags = [s.destructive ? 'destructive' : '', s.arms ? 'ARMS THE FLOW' : ''].filter(Boolean);
228
+ lines.push(` ${s.id} ${s.description}${tags.length ? ` ${tags.join(' ')}` : ''}`);
229
+ }
230
+ return lines.join('\n');
231
+ };
232
+
233
+ export type FlowApplyResult = { done: number; failed: number; skipped: number; steps: FlowStep[] };
234
+
235
+ /**
236
+ * Execute the flow steps.
237
+ *
238
+ * A created flow is laid down in one step from the outside — create, then trigger,
239
+ * then each node in execution order — because the flow id does not exist until the
240
+ * create returns, and every subsequent operation needs it. The alternative is
241
+ * placeholders in a second id space, which buys nothing here: the whole sequence
242
+ * is one flow's contents and either all of it lands or the flow is visibly
243
+ * half-built and reported as such.
244
+ */
245
+ export const applyFlows = async (
246
+ client: IpaasClient,
247
+ pipeId: number,
248
+ plan: FlowPlan,
249
+ opts: { dryRun?: boolean; continueOnError?: boolean } = {},
250
+ ): Promise<FlowApplyResult> => {
251
+ const ui = new Progress({
252
+ title: opts.dryRun ? `dry run · ${plan.steps.length} flow steps` : `applying ${plan.steps.length} flow steps`,
253
+ });
254
+ for (const s of plan.steps) {
255
+ ui.add({
256
+ id: s.id,
257
+ label: s.description,
258
+ state: 'pending',
259
+ tags: [...(s.destructive ? ['destructive'] : []), ...(s.arms ? ['arms'] : [])],
260
+ });
261
+ }
262
+ ui.start();
263
+
264
+ for (const step of plan.steps) {
265
+ if (opts.dryRun) {
266
+ step.status = 'skipped';
267
+ ui.update(step.id, { state: 'done', detail: 'not sent' });
268
+ continue;
269
+ }
270
+
271
+ step.status = 'running';
272
+ ui.update(step.id, { state: 'running' });
273
+ try {
274
+ if (step.createFlow) {
275
+ const flowId = await client.createFlow(pipeId, step.createFlow.displayName);
276
+ step.flowId = flowId;
277
+ const ops = buildFlowOperations(step.createFlow.tree);
278
+ for (const [i, op] of ops.entries()) {
279
+ ui.update(step.id, { detail: `${i + 1}/${ops.length} ${op.describe}` });
280
+ await client.operate(pipeId, flowId, op.type, op.request);
281
+ }
282
+ step.result = { flowId, operations: ops.length };
283
+ ui.update(step.id, { state: 'done', detail: `flow ${flowId}, ${ops.length} operations` });
284
+ } else if (step.createConnection) {
285
+ const made = await client.createPlaceholderConnection(pipeId, step.createConnection);
286
+ step.result = made;
287
+ ui.update(step.id, {
288
+ state: 'done',
289
+ detail: `${made.externalId} — placeholder, reconnect before enabling`,
290
+ });
291
+ } else if (step.deleteFlow) {
292
+ await client.deleteFlow(pipeId, step.flowId as string);
293
+ step.status = 'done';
294
+ ui.update(step.id, { state: 'done' });
295
+ } else if (step.operation) {
296
+ step.result = await client.operate(
297
+ pipeId,
298
+ step.flowId as string,
299
+ step.operation.type,
300
+ step.operation.request,
301
+ );
302
+ ui.update(step.id, { state: 'done' });
303
+ }
304
+ step.status = 'done';
305
+ } catch (e) {
306
+ step.status = 'failed';
307
+ step.error = (e as Error).message;
308
+ ui.update(step.id, { state: 'failed', detail: step.error });
309
+ if (!opts.continueOnError) break;
310
+ }
311
+ }
312
+
313
+ const count = (s: FlowStep['status']) => plan.steps.filter((x) => x.status === s).length;
314
+ ui.stop();
315
+
316
+ /**
317
+ * Placeholders are announced after the run, not buried in the step list.
318
+ *
319
+ * The host marks any connection ACTIVE without validating its credential, so
320
+ * nothing downstream will tell you these do not work — the flow simply fails on
321
+ * every trigger once enabled.
322
+ */
323
+ const placeholders = plan.steps.filter((s) => s.createConnection && s.status === 'done');
324
+ if (placeholders.length && !opts.dryRun) {
325
+ info('');
326
+ warn(
327
+ `${placeholders.length} placeholder connection(s) were created — the flow is built but not ` +
328
+ 'authenticated',
329
+ );
330
+ for (const s of placeholders) {
331
+ info(dim(` ${s.createConnection!.externalId} ${s.createConnection!.pieceName}`));
332
+ }
333
+ info(dim(' reconnect each under Advanced Automations > Connections before enabling a flow'));
334
+ }
335
+
336
+ return { done: count('done'), failed: count('failed'), skipped: count('skipped'), steps: plan.steps };
337
+ };
338
+
339
+ /**
340
+ * After applying, read the flows back and confirm every datapill survived exactly.
341
+ *
342
+ * This is the check nothing else can make. A migrating write path — `IMPORT_FLOW`
343
+ * is one — rewrites `{{step_1['output'].x}}` into `{{step_1['output']['output'].x}}`,
344
+ * which is structurally valid, passes every schema check, passes the platform's own
345
+ * validator, and resolves to nothing at runtime. Only comparing the stored string
346
+ * against the intended one finds it.
347
+ */
348
+ export const verifyFlowDatapills = async (
349
+ client: IpaasClient,
350
+ pipeId: number,
351
+ authored: FlowsSection,
352
+ ): Promise<Array<{ flow: string; node: string; at: string; sent: string; stored: string }>> => {
353
+ const drift: Array<{ flow: string; node: string; at: string; sent: string; stored: string }> = [];
354
+
355
+ const pillsOf = (v: unknown, at: string, out: Array<{ at: string; pill: string }> = []) => {
356
+ if (typeof v === 'string') {
357
+ for (const m of v.matchAll(/\{\{[^}]+\}\}/g)) out.push({ at, pill: m[0] });
358
+ return out;
359
+ }
360
+ if (Array.isArray(v)) {
361
+ v.forEach((x, i) => pillsOf(x, `${at}[${i}]`, out));
362
+ return out;
363
+ }
364
+ if (v && typeof v === 'object') for (const [k, val] of Object.entries(v)) pillsOf(val, at ? `${at}.${k}` : k, out);
365
+ return out;
366
+ };
367
+
368
+ for (const tree of authored.flows) {
369
+ const id = flowIdOf(tree);
370
+ if (!id) continue;
371
+ let live;
372
+ try {
373
+ live = unlinkFlow(await client.getFlow(pipeId, id));
374
+ } catch {
375
+ continue;
376
+ }
377
+ const liveByName = new Map(live.nodes.map((n) => [n.name, n]));
378
+ for (const node of tree.nodes) {
379
+ const other = liveByName.get(node.name);
380
+ if (!other) continue;
381
+ const sent = pillsOf(node['settings'], 'settings');
382
+ const stored = pillsOf(other['settings'], 'settings');
383
+ for (const one of sent) {
384
+ const sameKey = stored.filter((x) => x.at === one.at);
385
+ if (!sameKey.length) continue;
386
+ if (!sameKey.some((x) => x.pill === one.pill)) {
387
+ drift.push({
388
+ flow: String(tree.version['displayName'] ?? id),
389
+ node: node.name,
390
+ at: one.at,
391
+ sent: one.pill,
392
+ stored: sameKey[0]!.pill,
393
+ });
394
+ }
395
+ }
396
+ }
397
+ }
398
+ return drift;
399
+ };
400
+
401
+ export const reportDatapillDrift = (drift: Awaited<ReturnType<typeof verifyFlowDatapills>>) => {
402
+ if (!drift.length) return;
403
+ warn(`${drift.length} datapill(s) are not stored as they were sent`);
404
+ for (const d of drift.slice(0, 8)) {
405
+ info(dim(` ${d.flow} / ${d.node} ${d.at}`));
406
+ info(dim(` sent ${d.sent}`));
407
+ info(dim(` stored ${d.stored}`));
408
+ }
409
+ if (drift.some((d) => d.stored.includes("['output']['output']"))) {
410
+ info(dim(" ['output']['output'] is a double migration — the write path migrated an already-live flow"));
411
+ }
412
+ };
413
+
414
+ export { IpaasUnavailable };