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