@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,172 @@
1
+ import { debug } from "../util/log.js";
2
+ /**
3
+ * The closed sets — every place an authored value must come from a fixed list.
4
+ *
5
+ * This exists because of a structural gap, not a convenience. The schema
6
+ * publishes the lists:
7
+ *
8
+ * AutomationsEvents 10 values card_moved, field_updated, …
9
+ * AutomationsActions 15 values move_single_card, update_card_field, …
10
+ * FieldTypeId 24 values short_text, connector, …
11
+ *
12
+ * and then types the input fields that consume them as `ID` and `String`:
13
+ *
14
+ * CreateAutomationInput.event_id ID
15
+ * CreateAutomationInput.action_id ID
16
+ * CreatePhaseFieldInput.type_id String
17
+ *
18
+ * So GraphQL validates none of them. A wrong value passes query validation,
19
+ * reaches a runtime check, and comes back as the two words `is invalid` — no
20
+ * parameter name, no field name, no list of what would have been accepted. The
21
+ * list was always public; nothing was consulting it.
22
+ *
23
+ * Two consumers, and the split matters:
24
+ *
25
+ * - `pipe validate`, `plan` and `apply` read the cached copy and refuse
26
+ * offline, naming the value and what belongs there.
27
+ * - `REFERENCE.md` puts the same lists in the workspace, so whoever is editing
28
+ * the JSON — a person or an agent — reads what the API accepts instead of
29
+ * guessing and waiting for `is invalid`.
30
+ *
31
+ * Read once per pull, cached in `.ppc/reference.json`. Never assumed: an
32
+ * absent reference means the checks are skipped, not that a value is wrong.
33
+ */
34
+ /** Enums whose values get authored into workspace JSON by hand. */
35
+ const TRACKED_ENUMS = [
36
+ 'FieldTypeId',
37
+ 'AutomationsEvents',
38
+ 'AutomationsActions',
39
+ 'Colors',
40
+ 'FieldConditionFilter',
41
+ 'AutomationHttpAuthentication',
42
+ 'HttpRequestMethodTypeGQLEnum',
43
+ 'HttpRequestAddAuthenticationTo',
44
+ 'UpdateFieldValuesOperators',
45
+ 'DistributeAssignmentAutomationStrategyGQLEnum',
46
+ 'CardTypeEnum',
47
+ ];
48
+ const ENUMS_QUERY = `query ClosedSets {
49
+ __schema { types { name kind enumValues { name } } }
50
+ }`;
51
+ const CATALOGUE_QUERY = `query AutomationCatalogue($repoId: ID!) {
52
+ automationEvents { id acceptedParameters actionsBlacklist }
53
+ automationActions(repoId: $repoId) { id acceptedParameters eventsBlacklist }
54
+ }`;
55
+ /**
56
+ * Read every closed set for one pipe.
57
+ *
58
+ * Returns null rather than throwing: a token that cannot introspect, or a pipe
59
+ * whose action catalogue is refused, must degrade to "no reference" — which
60
+ * skips the checks — and never to a guess that would report valid work as broken.
61
+ */
62
+ export const readReference = async (client, repoId) => {
63
+ try {
64
+ const [schema, cat] = await Promise.all([
65
+ client.raw(ENUMS_QUERY),
66
+ client.raw(CATALOGUE_QUERY, { repoId: String(repoId) }),
67
+ ]);
68
+ const byName = new Map((schema?.__schema?.types ?? [])
69
+ .filter((t) => t.kind === 'ENUM')
70
+ .map((t) => [t.name, (t.enumValues ?? []).map((v) => v.name)]));
71
+ const enums = {};
72
+ for (const name of TRACKED_ENUMS) {
73
+ const values = byName.get(name);
74
+ /**
75
+ * An enum the endpoint no longer publishes is omitted, not recorded empty.
76
+ * An empty list would read as "nothing is valid here" and refuse every
77
+ * value in that position.
78
+ */
79
+ if (values?.length)
80
+ enums[name] = values;
81
+ }
82
+ const ref = {
83
+ readAt: new Date().toISOString(),
84
+ repoId: String(repoId),
85
+ enums,
86
+ events: (cat?.automationEvents ?? []).map((e) => ({
87
+ id: e.id,
88
+ acceptedParameters: e.acceptedParameters ?? [],
89
+ actionsBlacklist: e.actionsBlacklist ?? [],
90
+ })),
91
+ actions: (cat?.automationActions ?? []).map((a) => ({
92
+ id: a.id,
93
+ acceptedParameters: a.acceptedParameters ?? [],
94
+ eventsBlacklist: a.eventsBlacklist ?? [],
95
+ })),
96
+ };
97
+ debug(`reference: ${Object.keys(enums).length} enums, ${ref.events.length} events, ` +
98
+ `${ref.actions.length} actions for repo ${repoId}`);
99
+ return ref;
100
+ }
101
+ catch (e) {
102
+ debug(`reference unavailable: ${e.message}`);
103
+ return null;
104
+ }
105
+ };
106
+ /** The cached reference, in the shape the automation param check already takes. */
107
+ export const toCatalogue = (ref) => ({
108
+ events: new Map(ref.events.map((e) => [e.id, e])),
109
+ actions: new Map(ref.actions.map((a) => [a.id, a])),
110
+ });
111
+ export const enumValues = (ref, name) => {
112
+ const v = ref?.enums[name];
113
+ return v?.length ? v : null;
114
+ };
115
+ const table = (rows, head) => {
116
+ const w = Math.max(head[0].length, ...rows.map(([a]) => a.length));
117
+ return [
118
+ `| ${head[0].padEnd(w)} | ${head[1]} |`,
119
+ `| ${'-'.repeat(w)} | ${'-'.repeat(Math.max(3, head[1].length))} |`,
120
+ ...rows.map(([a, b]) => `| ${a.padEnd(w)} | ${b} |`),
121
+ ];
122
+ };
123
+ /**
124
+ * REFERENCE.md — the closed sets, on disk, next to the JSON being edited.
125
+ *
126
+ * Deliberately generated rather than written by hand. A hand-written list of
127
+ * what an API accepts is wrong the first time the API changes, and nothing tells
128
+ * you it went wrong; this one is re-read on every pull and stamped with when.
129
+ */
130
+ export const renderReferenceMd = (ref) => {
131
+ const lines = ['# What the API accepts', ''];
132
+ if (!ref) {
133
+ lines.push('The closed sets could not be read on the last pull, so this file is empty and', '`pipe validate` skips the checks that depend on it. That is not a claim that', 'any value is valid — it is the absence of a claim. Re-run `pipe pull` to fill it.', '');
134
+ return lines.join('\n');
135
+ }
136
+ lines.push(`Read from the API on ${ref.readAt} for pipe ${ref.repoId}. Regenerated by every`, '`pipe pull` — edit the workspace JSON, never this file.', '', 'These are the values the endpoint accepts in each position. They matter because', 'the schema publishes the lists but types the inputs that consume them as `ID`', 'and `String`, so a wrong value is not caught by GraphQL: it reaches a runtime', 'check and comes back as `is invalid`, naming nothing. `pipe validate` checks', 'these offline so a bad value is refused with its name before anything is sent.', '', '## Automation events', '', 'Each event accepts **only** the parameters listed. A parameter that belongs to a', 'different event is still just `is invalid`.', '');
137
+ for (const e of [...ref.events].sort((a, b) => a.id.localeCompare(b.id))) {
138
+ lines.push(`### \`${e.id}\``);
139
+ lines.push('');
140
+ lines.push(e.acceptedParameters.length
141
+ ? `\`event_params\`: ${e.acceptedParameters.map((p) => `\`${p}\``).join(', ')}`
142
+ : '`event_params`: none — this event takes no parameters.');
143
+ if (e.actionsBlacklist.length) {
144
+ lines.push('');
145
+ lines.push(`Cannot be paired with: ${e.actionsBlacklist.map((a) => `\`${a}\``).join(', ')}`);
146
+ }
147
+ lines.push('');
148
+ }
149
+ lines.push('## Automation actions', '', 'Actions are per-pipe: a pipe without AI enabled offers fewer than this list if', 'it was pulled from a different pipe. `field_map` is accepted on every action', 'that writes a field and is not listed below — the catalogue names', '`fields_map_order` beside it and stops.', '');
150
+ for (const a of [...ref.actions].sort((x, y) => x.id.localeCompare(y.id))) {
151
+ lines.push(`### \`${a.id}\``);
152
+ lines.push('');
153
+ lines.push(a.acceptedParameters.length
154
+ ? `\`action_params\`: ${a.acceptedParameters.map((p) => `\`${p}\``).join(', ')}`
155
+ : '`action_params`: none — this action takes no parameters.');
156
+ if (a.eventsBlacklist.length) {
157
+ lines.push('');
158
+ lines.push(`Cannot be triggered by: ${a.eventsBlacklist.map((e) => `\`${e}\``).join(', ')}`);
159
+ }
160
+ lines.push('');
161
+ }
162
+ const enumRows = Object.entries(ref.enums)
163
+ .sort(([a], [b]) => a.localeCompare(b))
164
+ .map(([name, values]) => [`\`${name}\``, values.map((v) => `\`${v}\``).join(', ')]);
165
+ if (enumRows.length) {
166
+ lines.push('## Other closed sets', '');
167
+ lines.push('Where these are used: `FieldTypeId` is a field\'s `type_id`; `Colors` is a', 'phase or pipe `color`; `FieldConditionFilter` is a field condition\'s', '`filter`; the `Http*` sets belong to `send_http_request` action params.', '');
168
+ lines.push(...table(enumRows, ['Set', 'Values']));
169
+ lines.push('');
170
+ }
171
+ return lines.join('\n');
172
+ };
@@ -0,0 +1,185 @@
1
+ import { gunzipSync } from 'node:zlib';
2
+ import { LIMITS } from "../config.js";
3
+ import { debug, progress, progressDone, step } from "../util/log.js";
4
+ import { retry, sleep } from "../util/pool.js";
5
+ /**
6
+ * `RepoSnapshotStatus` is `PENDING | UPLOADED | FAILED` — verified by
7
+ * introspection against api.pipefy.com. **UPLOADED is the success state**: the
8
+ * job has written the payload to S3 and `signedUrl` is available. The wider set
9
+ * is kept because this is an undocumented enum with no `schema_version`
10
+ * (ROUTE-B-V2.md ask 9), so a deployment that renames it degrades to a slow
11
+ * poll rather than a wrong answer.
12
+ */
13
+ const DONE = new Set(['uploaded', 'done', 'succeeded', 'success', 'completed', 'finished', 'ready']);
14
+ const FAILED = new Set(['failed', 'error', 'errored', 'cancelled', 'canceled']);
15
+ export const isDone = (s) => DONE.has(String(s ?? '').toLowerCase());
16
+ export const isFailed = (s) => FAILED.has(String(s ?? '').toLowerCase());
17
+ export class SnapshotClient {
18
+ client;
19
+ /** Whether this endpoint has snapshots at all — decided once, cached. */
20
+ available = null;
21
+ constructor(client) {
22
+ this.client = client;
23
+ }
24
+ /**
25
+ * Where poll progress goes. The snapshot wait is the longest thing this tool
26
+ * does (~63s), so whoever owns the screen renders it, rather than this client
27
+ * scribbling its own line into someone else's frame.
28
+ */
29
+ onProgress = null;
30
+ report(text) {
31
+ if (this.onProgress)
32
+ this.onProgress(text);
33
+ else
34
+ progress(text);
35
+ }
36
+ /**
37
+ * Snapshots are an entitlement, not a given. The whole point of the
38
+ * reconstruction path is that this can be false — so it is answered by
39
+ * introspection, up front, and never by a failed pull halfway through.
40
+ */
41
+ async isAvailable() {
42
+ if (this.available !== null)
43
+ return this.available;
44
+ const [mut, pipeFields] = await Promise.all([
45
+ this.client.hasMutation('createRepoSnapshot'),
46
+ this.client.typeFields('Pipe'),
47
+ ]);
48
+ this.available = mut && Boolean(pipeFields?.has('snapshot') || pipeFields?.has('snapshots'));
49
+ debug(`snapshot capability: createRepoSnapshot=${mut} pipe.snapshots=${pipeFields?.has('snapshots')}`);
50
+ return this.available;
51
+ }
52
+ /**
53
+ * `Pipe.snapshots` is a Relay `RepoSnapshotConnection`, not the flat array
54
+ * PLAN.md §1.1 recorded — so the rows come from `nodes`.
55
+ *
56
+ * `snapshots(last: N)` is not the most recent N — measured live, on a pipe
57
+ * with 86 snapshots: `last: 1` returned `sequenceIndex: 1`, the *oldest* one
58
+ * on record, while `first: 1` correctly returned `sequenceIndex: 86`. The
59
+ * connection is apparently ordered newest-first internally, the opposite of
60
+ * the usual Relay convention, which inverts what `first`/`last` each mean.
61
+ *
62
+ * This is not cosmetic: `pipe rollback`'s only-the-latest check, and
63
+ * `pipe apply`'s post-failure rollback offer, both call this to decide
64
+ * whether a version is still restorable. With `last`, that check compared
65
+ * against the *oldest* snapshot and refused every legitimate restore.
66
+ */
67
+ async list(repoId, limit = 20) {
68
+ const data = await this.client.tryRaw(`query($id: ID!, $first: Int!) {
69
+ pipe(id: $id) {
70
+ snapshots(first: $first) {
71
+ totalCount
72
+ nodes { versionId status sequenceIndex label createdAt }
73
+ }
74
+ }
75
+ }`, { id: String(repoId), first: limit }, 'list snapshots');
76
+ const rows = data?.pipe?.snapshots?.nodes ?? [];
77
+ return [...rows].sort((a, b) => (b.sequenceIndex ?? 0) - (a.sequenceIndex ?? 0));
78
+ }
79
+ /** `snapshot(versionId:)` takes an `ID`; declaring `String!` fails the query. */
80
+ async detail(repoId, versionId) {
81
+ const data = await this.client.tryRaw(`query($id: ID!, $v: ID!) {
82
+ pipe(id: $id) {
83
+ snapshot(versionId: $v) { versionId status signedUrl metrics uploadedAt error }
84
+ }
85
+ }`, { id: String(repoId), v: versionId }, 'snapshot detail');
86
+ return data?.pipe?.snapshot ?? null;
87
+ }
88
+ async create(repoId) {
89
+ const data = await this.client.raw(`mutation($input: CreateRepoSnapshotInput!) {
90
+ createRepoSnapshot(input: $input) { repoSnapshot { versionId status } }
91
+ }`, { input: { repoId: Number(repoId) } }, 'createRepoSnapshot');
92
+ return data.createRepoSnapshot.repoSnapshot.versionId;
93
+ }
94
+ async rename(repoId, versionId, label) {
95
+ const res = await this.client.tryRaw(`mutation($input: RenameRepoSnapshotInput!) {
96
+ renameRepoSnapshot(input: $input) { repoSnapshot { versionId label } }
97
+ }`, { input: { repoId: Number(repoId), versionId, label } }, 'renameRepoSnapshot');
98
+ return res !== null;
99
+ }
100
+ /** Poll to a terminal state. ~63s is normal, so the wait is visible and bounded. */
101
+ async waitFor(repoId, versionId) {
102
+ const started = Date.now();
103
+ let unreadable = 0;
104
+ for (;;) {
105
+ const d = await this.detail(repoId, versionId);
106
+ const elapsed = Math.round((Date.now() - started) / 1000);
107
+ if (d && isDone(d.status)) {
108
+ if (!this.onProgress)
109
+ progressDone();
110
+ return d;
111
+ }
112
+ if (d && isFailed(d.status)) {
113
+ if (!this.onProgress)
114
+ progressDone();
115
+ throw new Error(`snapshot ${versionId} ${d.status}${d.error ? `: ${d.error}` : ''}`);
116
+ }
117
+ /**
118
+ * A null detail means the *query* failed, not that the snapshot is
119
+ * pending. Polling a broken query for five minutes and then reporting
120
+ * "still unknown" hides the real cause — which is how a schema drift in
121
+ * this query cost a wasted snapshot and a 303s wait. Fail fast and say so.
122
+ */
123
+ if (!d) {
124
+ unreadable++;
125
+ if (unreadable >= 3) {
126
+ if (!this.onProgress)
127
+ progressDone();
128
+ throw new Error(`snapshot ${versionId} status is unreadable — pipe.snapshot(versionId:) did not answer 3 times. ` +
129
+ `Run with --verbose to see the GraphQL error; the snapshot itself may well have been created.`);
130
+ }
131
+ }
132
+ else {
133
+ unreadable = 0;
134
+ }
135
+ if (Date.now() - started > LIMITS.snapshotTimeoutMs) {
136
+ if (!this.onProgress)
137
+ progressDone();
138
+ throw new Error(`snapshot ${versionId} still ${d?.status ?? 'unreadable'} after ${elapsed}s`);
139
+ }
140
+ this.report(`${String(d?.status ?? 'unreadable').toLowerCase()} ${elapsed}s (~63s typical)`);
141
+ await sleep(LIMITS.snapshotPollMs);
142
+ }
143
+ }
144
+ /**
145
+ * Download the payload. The signed URL is re-queried immediately before the
146
+ * fetch and never stored, and the raw gzip bytes are returned alongside the
147
+ * parsed payload so the caller can cache the compressed form.
148
+ */
149
+ async download(repoId, versionId) {
150
+ const d = await this.detail(repoId, versionId);
151
+ if (!d?.signedUrl)
152
+ throw new Error(`snapshot ${versionId} has no signedUrl (status ${d?.status ?? 'unknown'})`);
153
+ const bytes = await retry(async () => {
154
+ const res = await fetch(d.signedUrl);
155
+ if (!res.ok) {
156
+ const err = new Error(`snapshot download HTTP ${res.status}`);
157
+ err.retryable = res.status >= 500 || res.status === 429;
158
+ throw err;
159
+ }
160
+ return Buffer.from(await res.arrayBuffer());
161
+ }, { label: 'snapshot download' });
162
+ const gzipped = bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b;
163
+ const raw = gzipped ? gunzipSync(bytes) : bytes;
164
+ return { payload: JSON.parse(raw.toString('utf8')), gzip: gzipped ? bytes : null, raw };
165
+ }
166
+ /** Create, wait, download — the whole read in one call. */
167
+ async pull(repoId, label) {
168
+ if (this.onProgress)
169
+ this.report('requesting a snapshot');
170
+ else
171
+ step(`creating snapshot of repo ${repoId}`);
172
+ const versionId = await this.create(repoId);
173
+ await this.waitFor(repoId, versionId);
174
+ if (label)
175
+ await this.rename(repoId, versionId, label);
176
+ this.report('downloading');
177
+ const dl = await this.download(repoId, versionId);
178
+ return { ...dl, versionId };
179
+ }
180
+ /** Most recent already-complete snapshot, so a pull can cost zero snapshots. */
181
+ async latestComplete(repoId) {
182
+ const rows = await this.list(repoId);
183
+ return rows.find((r) => isDone(r.status)) ?? null;
184
+ }
185
+ }
@@ -0,0 +1,158 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { promisify } from 'node:util';
6
+ import { debug } from "../util/log.js";
7
+ /**
8
+ * Bridge to the official Pipefy AI toolkit (github.com/pipefy/ai-toolkit).
9
+ *
10
+ * `pipe` does not implement Pipefy login. When the toolkit is installed, its
11
+ * session *is* the credential: `pipefy auth login` runs the browser OAuth flow
12
+ * and stores a session in the OS keychain, and everything here does is ask the
13
+ * toolkit for the current bearer.
14
+ *
15
+ * That means one login for the MCP server, the `pipefy` CLI and this tool, and
16
+ * it means the keychain, the refresh grant and the credential precedence stay in
17
+ * one place — the toolkit's — rather than being reimplemented against a private
18
+ * store shape that can change under us.
19
+ *
20
+ * The bearer is short-lived and never persisted here. It is fetched per process
21
+ * and re-fetched when the API rejects it, which is why callers take a provider
22
+ * rather than a string.
23
+ */
24
+ const exec = promisify(execFile);
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const HELPER = join(here, 'toolkit_bearer.py');
27
+ const WINDOWS = process.platform === 'win32';
28
+ /** `pipefy` / `pipefy-mcp-server`, wherever uv put them. */
29
+ const toolBinDir = () => {
30
+ const home = process.env.USERPROFILE ?? process.env.HOME;
31
+ if (!home)
32
+ return null;
33
+ const dir = join(home, '.local', 'bin');
34
+ return existsSync(dir) ? dir : null;
35
+ };
36
+ export const toolkitBinary = (name) => {
37
+ const dir = toolBinDir();
38
+ if (!dir)
39
+ return null;
40
+ const path = join(dir, WINDOWS ? `${name}.exe` : name);
41
+ return existsSync(path) ? path : null;
42
+ };
43
+ /**
44
+ * The interpreter that can import `pipefy_auth`.
45
+ *
46
+ * `uv tool install` gives each tool its own environment, and `uv tool dir` is
47
+ * the documented way to find them — so the layout is asked for rather than
48
+ * assumed. `uv run --with` is the fallback for a machine where the CLI is not
49
+ * installed as a tool but uv is available.
50
+ */
51
+ const findInterpreter = async () => {
52
+ const roots = [];
53
+ try {
54
+ const { stdout } = await exec('uv', ['tool', 'dir'], { timeout: 15_000 });
55
+ const dir = stdout.trim();
56
+ if (dir)
57
+ roots.push(dir);
58
+ }
59
+ catch {
60
+ debug('uv tool dir failed — uv may not be installed');
61
+ }
62
+ const appdata = process.env.APPDATA;
63
+ if (appdata)
64
+ roots.push(join(appdata, 'uv', 'tools'));
65
+ const home = process.env.USERPROFILE ?? process.env.HOME;
66
+ if (home) {
67
+ roots.push(join(home, '.local', 'share', 'uv', 'tools'));
68
+ roots.push(join(home, 'Library', 'Application Support', 'uv', 'tools'));
69
+ }
70
+ for (const root of roots) {
71
+ for (const tool of ['pipefy-cli', 'pipefy-mcp-server']) {
72
+ const python = WINDOWS
73
+ ? join(root, tool, 'Scripts', 'python.exe')
74
+ : join(root, tool, 'bin', 'python');
75
+ if (existsSync(python)) {
76
+ debug(`toolkit interpreter: ${python}`);
77
+ return { command: python, args: [] };
78
+ }
79
+ }
80
+ }
81
+ // No installed tool env: let uv build an ephemeral one. Slower, and it needs
82
+ // the network the first time, so it is deliberately the last resort.
83
+ try {
84
+ await exec('uv', ['--version'], { timeout: 15_000 });
85
+ debug('falling back to `uv run --with pipefy-auth`');
86
+ return { command: 'uv', args: ['run', '--quiet', '--with', 'pipefy-auth', 'python'] };
87
+ }
88
+ catch {
89
+ return null;
90
+ }
91
+ };
92
+ let cachedInterpreter;
93
+ /** Is the toolkit usable on this machine, and does it currently hold a credential? */
94
+ export const inspectToolkit = async () => {
95
+ cachedInterpreter ??= await findInterpreter();
96
+ if (!cachedInterpreter)
97
+ return { installed: false };
98
+ const credential = await readBearer();
99
+ if (!credential)
100
+ return { installed: false };
101
+ return {
102
+ installed: true,
103
+ cliPath: toolkitBinary('pipefy'),
104
+ mcpServerPath: toolkitBinary('pipefy-mcp-server'),
105
+ interpreter: cachedInterpreter.command,
106
+ credential,
107
+ };
108
+ };
109
+ /**
110
+ * Ask the toolkit for a bearer. `null` means the toolkit could not be used at
111
+ * all — distinct from a `{ ok: false }` result, which is the toolkit answering
112
+ * that it holds no credential.
113
+ */
114
+ export const readBearer = async () => {
115
+ cachedInterpreter ??= await findInterpreter();
116
+ if (!cachedInterpreter)
117
+ return null;
118
+ if (!existsSync(HELPER)) {
119
+ debug(`toolkit helper missing at ${HELPER}`);
120
+ return null;
121
+ }
122
+ try {
123
+ const { stdout } = await exec(cachedInterpreter.command, [...cachedInterpreter.args, HELPER], { timeout: 60_000, maxBuffer: 1024 * 1024 });
124
+ const parsed = JSON.parse(stdout.trim());
125
+ debug(`toolkit credential: ${parsed.ok ? parsed.source : parsed.reason}`);
126
+ return parsed;
127
+ }
128
+ catch (e) {
129
+ debug(`toolkit bearer helper failed: ${e.message}`);
130
+ return null;
131
+ }
132
+ };
133
+ /** Run `pipefy auth login`, inheriting the terminal so the browser flow works. */
134
+ export const runToolkitLogin = async (extraArgs = []) => {
135
+ const cli = toolkitBinary('pipefy');
136
+ const { spawn } = await import('node:child_process');
137
+ const command = cli ?? 'pipefy';
138
+ return new Promise((resolve) => {
139
+ const child = spawn(command, ['auth', 'login', ...extraArgs], { stdio: 'inherit' });
140
+ child.on('error', () => resolve(127));
141
+ child.on('exit', (code) => resolve(code ?? 1));
142
+ });
143
+ };
144
+ /** `pipefy auth status --json`, for `pipe doctor`. Null when unavailable. */
145
+ export const toolkitAuthStatus = async () => {
146
+ const cli = toolkitBinary('pipefy');
147
+ if (!cli)
148
+ return null;
149
+ try {
150
+ const { stdout } = await exec(cli, ['auth', 'status', '--json'], { timeout: 60_000 });
151
+ return JSON.parse(stdout.trim());
152
+ }
153
+ catch (e) {
154
+ debug(`pipefy auth status failed: ${e.message}`);
155
+ return null;
156
+ }
157
+ };
158
+ export const INSTALL_HINT = 'install the Pipefy AI toolkit (github.com/pipefy/ai-toolkit): `uv tool install pipefy-cli`, then `pipefy auth login`';
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The integration surface of a pipe, pulled out of the payload as its own
3
+ * artifact.
4
+ *
5
+ * "Integration" here means every way this pipe touches something outside
6
+ * itself, because those are the parts of a change whose blast radius leaves the
7
+ * pipe: a webhook fires at a third party, a pipe relation writes cards into
8
+ * another process, a cross-repo automation does both. They are also the parts
9
+ * that carry credentials, which is why the rendered summary is redacted and the
10
+ * raw rows go in a file the workspace .gitignore covers.
11
+ *
12
+ * Recorded per run rather than derived on demand: a run is the only moment when
13
+ * "what did this pipe reach, before and after" is knowable, and by the next
14
+ * apply the answer has changed.
15
+ */
16
+ /** Automation actions that reach outside the pipe they are triggered in. */
17
+ const OUTBOUND_ACTIONS = new Set([
18
+ 'http_request',
19
+ 'send_email',
20
+ 'send_email_template',
21
+ 'create_card',
22
+ 'create_card_relation',
23
+ 'move_single_card',
24
+ 'create_table_record',
25
+ 'update_table_record',
26
+ ]);
27
+ const s = (v) => (v === null || v === undefined ? null : String(v));
28
+ const hostOf = (url) => {
29
+ const raw = s(url);
30
+ if (!raw)
31
+ return '(no url)';
32
+ try {
33
+ return new URL(raw).host;
34
+ }
35
+ catch {
36
+ return raw.slice(0, 40);
37
+ }
38
+ };
39
+ /** Whether a row holds anything bearer-equivalent. */
40
+ const bearsSecret = (row) => {
41
+ const j = JSON.stringify(row).toLowerCase();
42
+ return (j.includes('authentication_value') ||
43
+ j.includes('authentication_key') ||
44
+ j.includes('token') ||
45
+ j.includes('secret') ||
46
+ /[?&](key|token|sig|signature|auth)=/.test(j));
47
+ };
48
+ export const integrationsFrom = (env) => {
49
+ const rel = env.payload.relations;
50
+ const repoId = Number(env.payload.id);
51
+ const webhooks = rel.webhooks ?? [];
52
+ const relations = rel.pipe_relations ?? [];
53
+ const automations = rel.automations ?? [];
54
+ const inboxes = rel.email_inboxes ?? [];
55
+ const fields = rel.fields ?? [];
56
+ const outbound = automations.filter((a) => {
57
+ const action = s(a['action_id']) ?? '';
58
+ const target = s(a['action_repo_id']);
59
+ const crossRepo = target !== null && target !== s(a['event_repo_id']);
60
+ return crossRepo || OUTBOUND_ACTIONS.has(action);
61
+ });
62
+ const connectedFields = fields.filter((f) => s(f['connected_repo_id']) ?? s(f['connected_pipe_id']));
63
+ const edges = [
64
+ ...webhooks.map((w) => ({
65
+ kind: 'webhook',
66
+ target: hostOf(w['url']),
67
+ id: s(w['id']),
68
+ detail: `${s(w['name']) ?? 'unnamed'} on ` +
69
+ `${Array.isArray(w['actions']) ? w['actions'].join(', ') : (s(w['actions']) ?? 'no actions')}`,
70
+ bearsSecret: bearsSecret(w),
71
+ })),
72
+ ...relations.map((r) => ({
73
+ kind: 'pipe_relation',
74
+ target: `repo ${s(r['child_id']) ?? s(r['parent_id']) ?? '?'}`,
75
+ id: s(r['id']),
76
+ detail: `${s(r['name']) ?? 'unnamed'} — parent ${s(r['parent_id'])} to child ${s(r['child_id'])}`,
77
+ })),
78
+ ...outbound.map((a) => ({
79
+ kind: 'automation',
80
+ target: s(a['action_repo_id']) && s(a['action_repo_id']) !== String(repoId)
81
+ ? `repo ${s(a['action_repo_id'])}`
82
+ : (s(a['action_id']) ?? 'action'),
83
+ id: s(a['id']),
84
+ detail: `${s(a['name']) ?? 'unnamed'} — ${s(a['event_id'])} to ${s(a['action_id'])}` +
85
+ `${a['active'] === false ? ' (inactive)' : ''}`,
86
+ bearsSecret: bearsSecret(a),
87
+ })),
88
+ ...connectedFields.map((f) => ({
89
+ kind: 'connected_field',
90
+ target: `repo ${s(f['connected_repo_id']) ?? s(f['connected_pipe_id'])}`,
91
+ id: s(f['id']),
92
+ // Payload rows spell the type `type_id`; a reconstructed row may spell it
93
+ // `type`. Neither is guaranteed, so both are tried before giving up.
94
+ detail: `field "${s(f['label']) ?? s(f['name']) ?? '?'}" (${s(f['type_id']) ?? s(f['type']) ?? 'unknown type'})`,
95
+ })),
96
+ ...inboxes.map((i) => ({
97
+ kind: 'email_inbox',
98
+ target: s(i['email']) ?? s(i['name']) ?? 'inbox',
99
+ id: s(i['id']),
100
+ detail: 'inbound email',
101
+ })),
102
+ ];
103
+ const connected = new Set();
104
+ const note = (v) => {
105
+ const n = Number(v);
106
+ if (Number.isFinite(n) && n !== repoId && n !== 0)
107
+ connected.add(n);
108
+ };
109
+ for (const r of relations) {
110
+ note(r['parent_id']);
111
+ note(r['child_id']);
112
+ }
113
+ for (const a of outbound)
114
+ note(a['action_repo_id']);
115
+ for (const f of connectedFields)
116
+ note(f['connected_repo_id'] ?? f['connected_pipe_id']);
117
+ const form = (rel.public_forms ?? [])[0] ?? null;
118
+ return {
119
+ generatedAt: new Date().toISOString(),
120
+ repoId,
121
+ repoName: String(env.payload['name'] ?? repoId),
122
+ coverage: {
123
+ webhooks: env.coverage.webhooks,
124
+ pipe_relations: env.coverage.pipe_relations,
125
+ automations: env.coverage.automations,
126
+ email_inboxes: env.coverage.email_inboxes,
127
+ },
128
+ counts: {
129
+ webhooks: webhooks.length,
130
+ pipe_relations: relations.length,
131
+ outbound_automations: outbound.length,
132
+ connected_fields: connectedFields.length,
133
+ },
134
+ edges,
135
+ connected_repo_ids: [...connected].sort((a, b) => a - b),
136
+ webhooks,
137
+ pipe_relations: relations,
138
+ outbound_automations: outbound,
139
+ email_inboxes: inboxes,
140
+ public_form: form ? { open: Boolean(env.payload['public']), title: form['title'] ?? null } : null,
141
+ };
142
+ };
143
+ /** The integration surface as report prose. Hosts kept, credentials never. */
144
+ export const renderIntegrations = (v) => {
145
+ const lines = [];
146
+ const gaps = Object.entries(v.coverage).filter(([, c]) => c !== 'complete');
147
+ if (gaps.length) {
148
+ lines.push(`> Coverage: ${gaps.map(([k, c]) => `${k} is ${c}`).join(', ')}. ` +
149
+ 'An absent row below is not evidence that nothing is there.', '');
150
+ }
151
+ if (!v.edges.length) {
152
+ lines.push('This pipe reaches nothing outside itself.');
153
+ return lines;
154
+ }
155
+ lines.push('| Kind | Target | Id | Detail |', '| --- | --- | --- | --- |');
156
+ for (const e of v.edges) {
157
+ lines.push(`| ${e.kind.replace(/_/g, ' ')} | ${e.target}${e.bearsSecret ? ' (key)' : ''} | ${e.id ?? '-'} | ` +
158
+ `${e.detail.replace(/\|/g, '\\|')} |`);
159
+ }
160
+ if (v.edges.some((e) => e.bearsSecret)) {
161
+ lines.push('', '(key) marks a row that carries a credential. The value is in `integrations.json`, ' +
162
+ 'which the workspace .gitignore covers.');
163
+ }
164
+ return lines;
165
+ };