@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.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- 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`';
|