@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,219 @@
|
|
|
1
|
+
import { debug } from '../util/log.ts';
|
|
2
|
+
import type { PipefyClient } from './client.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The automation event and action catalogue, read from the **public** API.
|
|
6
|
+
*
|
|
7
|
+
* Pipefy publishes exactly what each event and each action accepts —
|
|
8
|
+
* `automationEvents.acceptedParameters`, `automationActions.acceptedParameters` —
|
|
9
|
+
* and which combinations are refused. Without reading it, a wrong parameter name
|
|
10
|
+
* is only discovered by sending the mutation, and the answer is the single word
|
|
11
|
+
* `is invalid`, from both the public and the internal endpoint. Nothing names the
|
|
12
|
+
* parameter, the event, or the action.
|
|
13
|
+
*
|
|
14
|
+
* The case that motivated this: an automation meaning "when a card lands in
|
|
15
|
+
* New Request, move it to Archived" was written as
|
|
16
|
+
*
|
|
17
|
+
* event_id: 'card_moved', event_params: { inPhaseId: '<phaseId>' }
|
|
18
|
+
*
|
|
19
|
+
* `inPhaseId` is real — it belongs to `card_inbox_received_email`. `card_moved`
|
|
20
|
+
* accepts only `to_phase_id`. Both endpoints answered `is invalid` and the apply
|
|
21
|
+
* failed at step one with nothing to act on.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export type AutomationEventSpec = {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Parameter names this event accepts, in the API's own snake_case. */
|
|
27
|
+
acceptedParameters: string[];
|
|
28
|
+
/** Actions this event cannot be paired with. */
|
|
29
|
+
actionsBlacklist: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AutomationActionSpec = {
|
|
33
|
+
id: string;
|
|
34
|
+
acceptedParameters: string[];
|
|
35
|
+
eventsBlacklist: string[];
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type AutomationCatalogue = {
|
|
39
|
+
events: Map<string, AutomationEventSpec>;
|
|
40
|
+
actions: Map<string, AutomationActionSpec>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const EVENTS_QUERY = `query AutomationEvents {
|
|
44
|
+
automationEvents { id acceptedParameters actionsBlacklist }
|
|
45
|
+
}`;
|
|
46
|
+
|
|
47
|
+
const ACTIONS_QUERY = `query AutomationActions($repoId: ID!) {
|
|
48
|
+
automationActions(repoId: $repoId) { id acceptedParameters eventsBlacklist }
|
|
49
|
+
}`;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Read the catalogue for one pipe. Actions are per-repo (a pipe without AI
|
|
53
|
+
* enabled offers fewer), events are global.
|
|
54
|
+
*/
|
|
55
|
+
export const readAutomationCatalogue = async (
|
|
56
|
+
client: PipefyClient,
|
|
57
|
+
repoId: number | string,
|
|
58
|
+
): Promise<AutomationCatalogue> => {
|
|
59
|
+
const [ev, ac] = await Promise.all([
|
|
60
|
+
client.raw<{ automationEvents: AutomationEventSpec[] }>(EVENTS_QUERY),
|
|
61
|
+
client.raw<{ automationActions: AutomationActionSpec[] }>(ACTIONS_QUERY, { repoId: String(repoId) }),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
const events = new Map<string, AutomationEventSpec>();
|
|
65
|
+
for (const e of ev?.automationEvents ?? []) {
|
|
66
|
+
events.set(e.id, {
|
|
67
|
+
id: e.id,
|
|
68
|
+
acceptedParameters: e.acceptedParameters ?? [],
|
|
69
|
+
actionsBlacklist: e.actionsBlacklist ?? [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
const actions = new Map<string, AutomationActionSpec>();
|
|
73
|
+
for (const a of ac?.automationActions ?? []) {
|
|
74
|
+
actions.set(a.id, {
|
|
75
|
+
id: a.id,
|
|
76
|
+
acceptedParameters: a.acceptedParameters ?? [],
|
|
77
|
+
eventsBlacklist: a.eventsBlacklist ?? [],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
debug(`automation catalogue: ${events.size} events, ${actions.size} actions for repo ${repoId}`);
|
|
81
|
+
return { events, actions };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parameter names the payload spells differently from the API.
|
|
86
|
+
*
|
|
87
|
+
* The stored row and the mutation input disagree in both directions, so a check
|
|
88
|
+
* against `acceptedParameters` has to normalise first or it reports correct
|
|
89
|
+
* automations as broken.
|
|
90
|
+
*/
|
|
91
|
+
const PARAM_ALIASES: Record<string, string> = {
|
|
92
|
+
// event params
|
|
93
|
+
inPhaseId: 'in_phase_id',
|
|
94
|
+
fromPhaseId: 'from_phase_id',
|
|
95
|
+
toPhaseId: 'to_phase_id',
|
|
96
|
+
triggerFieldIds: 'trigger_field_ids',
|
|
97
|
+
triggerAutomationId: 'trigger_automation_id',
|
|
98
|
+
kindOfSla: 'kind_of_sla',
|
|
99
|
+
// action params
|
|
100
|
+
fieldsMapOrder: 'fields_map_order',
|
|
101
|
+
cardId: 'card_id',
|
|
102
|
+
toPhaseIdAction: 'to_phase_id',
|
|
103
|
+
taskParams: 'task_params',
|
|
104
|
+
aiParams: 'ai_params',
|
|
105
|
+
aiBehaviorParams: 'ai_behavior_params',
|
|
106
|
+
slaParams: 'sla_params',
|
|
107
|
+
httpMethod: 'http_method',
|
|
108
|
+
emailTemplateId: 'email_template_id',
|
|
109
|
+
authenticationKey: 'authentication_key',
|
|
110
|
+
authenticationValue: 'authentication_value',
|
|
111
|
+
authenticationAddTo: 'authentication_add_to',
|
|
112
|
+
authenticationType: 'authentication_type',
|
|
113
|
+
oauth2Uuid: 'oauth2_uuid',
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const canonical = (name: string) => PARAM_ALIASES[name] ?? name;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Params the API accepts on every action regardless of the catalogue.
|
|
120
|
+
*
|
|
121
|
+
* `field_map` carries the values an `update_card_field` or `create_card` writes
|
|
122
|
+
* and is not listed in `acceptedParameters` — the catalogue lists
|
|
123
|
+
* `fields_map_order` beside it and stops. Flagging `field_map` would refuse every
|
|
124
|
+
* automation that writes a field, which is most of them.
|
|
125
|
+
*/
|
|
126
|
+
const ALWAYS_ALLOWED_ACTION_PARAMS = new Set(['field_map', 'schema']);
|
|
127
|
+
|
|
128
|
+
export type ParamFinding = {
|
|
129
|
+
severity: 'error' | 'warning';
|
|
130
|
+
where: 'event' | 'action' | 'pairing';
|
|
131
|
+
message: string;
|
|
132
|
+
fix?: string;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check one automation against the catalogue, before anything is sent.
|
|
137
|
+
*
|
|
138
|
+
* Returns findings rather than throwing, so the compiler can refuse the step and
|
|
139
|
+
* name the parameter — which is the whole point. An unknown event or action is an
|
|
140
|
+
* error; a catalogue this pipe does not offer is silence, not a guess.
|
|
141
|
+
*/
|
|
142
|
+
export const checkAutomationParams = (
|
|
143
|
+
auto: Record<string, unknown>,
|
|
144
|
+
catalogue: AutomationCatalogue,
|
|
145
|
+
): ParamFinding[] => {
|
|
146
|
+
const findings: ParamFinding[] = [];
|
|
147
|
+
const eventId = String(auto['event_id'] ?? '');
|
|
148
|
+
const actionId = String(auto['action_id'] ?? '');
|
|
149
|
+
|
|
150
|
+
const event = catalogue.events.get(eventId);
|
|
151
|
+
const action = catalogue.actions.get(actionId);
|
|
152
|
+
|
|
153
|
+
if (eventId && !event) {
|
|
154
|
+
findings.push({
|
|
155
|
+
severity: 'error',
|
|
156
|
+
where: 'event',
|
|
157
|
+
message: `event_id ${JSON.stringify(eventId)} is not an event this endpoint offers`,
|
|
158
|
+
fix: `available: ${[...catalogue.events.keys()].join(', ')}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (actionId && !action) {
|
|
162
|
+
findings.push({
|
|
163
|
+
severity: 'error',
|
|
164
|
+
where: 'action',
|
|
165
|
+
message: `action_id ${JSON.stringify(actionId)} is not an action this pipe offers`,
|
|
166
|
+
fix: `available: ${[...catalogue.actions.keys()].join(', ')}`,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (event && action) {
|
|
171
|
+
if (event.actionsBlacklist.includes(actionId)) {
|
|
172
|
+
findings.push({
|
|
173
|
+
severity: 'error',
|
|
174
|
+
where: 'pairing',
|
|
175
|
+
message: `event ${eventId} cannot be paired with action ${actionId}`,
|
|
176
|
+
});
|
|
177
|
+
} else if (action.eventsBlacklist.includes(eventId)) {
|
|
178
|
+
findings.push({
|
|
179
|
+
severity: 'error',
|
|
180
|
+
where: 'pairing',
|
|
181
|
+
message: `action ${actionId} cannot be triggered by event ${eventId}`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (event) {
|
|
187
|
+
const accepted = new Set(event.acceptedParameters.map(canonical));
|
|
188
|
+
for (const key of Object.keys((auto['event_params'] ?? {}) as Record<string, unknown>)) {
|
|
189
|
+
const name = canonical(key);
|
|
190
|
+
if (accepted.has(name)) continue;
|
|
191
|
+
findings.push({
|
|
192
|
+
severity: 'error',
|
|
193
|
+
where: 'event',
|
|
194
|
+
message: `event ${eventId} does not accept event_params.${key}`,
|
|
195
|
+
fix: accepted.size
|
|
196
|
+
? `it accepts: ${[...accepted].join(', ')}`
|
|
197
|
+
: `it accepts no parameters — remove event_params.${key}`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (action) {
|
|
203
|
+
const accepted = new Set(action.acceptedParameters.map(canonical));
|
|
204
|
+
for (const key of Object.keys((auto['action_params'] ?? {}) as Record<string, unknown>)) {
|
|
205
|
+
const name = canonical(key);
|
|
206
|
+
if (accepted.has(name) || ALWAYS_ALLOWED_ACTION_PARAMS.has(name)) continue;
|
|
207
|
+
findings.push({
|
|
208
|
+
severity: 'error',
|
|
209
|
+
where: 'action',
|
|
210
|
+
message: `action ${actionId} does not accept action_params.${key}`,
|
|
211
|
+
fix: accepted.size
|
|
212
|
+
? `it accepts: ${[...accepted].join(', ')}`
|
|
213
|
+
: `it accepts no parameters — remove action_params.${key}`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return findings;
|
|
219
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { debug } from '../util/log.ts';
|
|
2
|
+
import type { PipefyClient } from './client.ts';
|
|
3
|
+
import type { InternalApi } from './internal.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Capability detection. PLAN.md §1.2: `restoreRepoSnapshot` and
|
|
7
|
+
* `createRepoDraft` carry the entire safety model but do not exist on every
|
|
8
|
+
* endpoint, so the app detects them at startup and degrades explicitly rather
|
|
9
|
+
* than failing mid-apply.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type Capabilities = {
|
|
13
|
+
/** Read paths. */
|
|
14
|
+
snapshots: boolean;
|
|
15
|
+
/** Safety primitives. */
|
|
16
|
+
restoreRepoSnapshot: boolean;
|
|
17
|
+
/** Which spelling this endpoint has, so the caller does not guess. */
|
|
18
|
+
restoreMutation: 'restoreRepoToSnapshot' | 'restoreRepoSnapshot' | null;
|
|
19
|
+
/** Whether a restore is limited to the newest snapshot. Measured, not assumed. */
|
|
20
|
+
restoreLatestOnly: boolean;
|
|
21
|
+
createRepoDraft: boolean;
|
|
22
|
+
/** Route B, tracked so `pipe doctor` reports the day it lands. */
|
|
23
|
+
importRepoSnapshot: boolean;
|
|
24
|
+
/** Public write paths this build depends on. */
|
|
25
|
+
mutations: Record<string, boolean>;
|
|
26
|
+
/** The internal endpoint — phase jumps and automation details. */
|
|
27
|
+
internalApi: 'ok' | 'unavailable' | 'unknown';
|
|
28
|
+
checkedAt: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Mutations the plan compiler emits, checked as a set so gaps are visible up front. */
|
|
32
|
+
export const REQUIRED_MUTATIONS = [
|
|
33
|
+
'updatePipe',
|
|
34
|
+
'createPhase',
|
|
35
|
+
'updatePhase',
|
|
36
|
+
'deletePhase',
|
|
37
|
+
'createPhaseField',
|
|
38
|
+
'updatePhaseField',
|
|
39
|
+
'deletePhaseField',
|
|
40
|
+
'createLabel',
|
|
41
|
+
'updateLabel',
|
|
42
|
+
'deleteLabel',
|
|
43
|
+
'createWebhook',
|
|
44
|
+
'updateWebhook',
|
|
45
|
+
'deleteWebhook',
|
|
46
|
+
'createFieldCondition',
|
|
47
|
+
'updateFieldCondition',
|
|
48
|
+
'deleteFieldCondition',
|
|
49
|
+
'setFieldConditionOrder',
|
|
50
|
+
'createAutomation',
|
|
51
|
+
'updateAutomation',
|
|
52
|
+
'deleteAutomation',
|
|
53
|
+
'createPipeRelation',
|
|
54
|
+
'updatePipeRelation',
|
|
55
|
+
'deletePipeRelation',
|
|
56
|
+
'phaseSettings',
|
|
57
|
+
'updateRepoPreferences',
|
|
58
|
+
'archiveField',
|
|
59
|
+
'unarchiveField',
|
|
60
|
+
'moveCardToPhase',
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
export const detect = async (client: PipefyClient, internal?: InternalApi): Promise<Capabilities> => {
|
|
64
|
+
const mutationFields = await client.typeFields('Mutation');
|
|
65
|
+
const pipeFields = await client.typeFields('Pipe');
|
|
66
|
+
const has = (n: string) => Boolean(mutationFields?.has(n));
|
|
67
|
+
|
|
68
|
+
const mutations: Record<string, boolean> = {};
|
|
69
|
+
for (const m of REQUIRED_MUTATIONS) mutations[m] = has(m);
|
|
70
|
+
|
|
71
|
+
const caps: Capabilities = {
|
|
72
|
+
snapshots: has('createRepoSnapshot') && Boolean(pipeFields?.has('snapshots') || pipeFields?.has('snapshot')),
|
|
73
|
+
/**
|
|
74
|
+
* Both spellings, because this build was looking for the wrong one.
|
|
75
|
+
*
|
|
76
|
+
* `restoreRepoSnapshot` does not exist on this endpoint and never did.
|
|
77
|
+
* **`restoreRepoToSnapshot`** does — "Restores a Pipe structure to its latest
|
|
78
|
+
* uploaded snapshot" — so every apply has printed "no one-command rollback"
|
|
79
|
+
* beside a snapshot that could in fact be restored, and `pipe rollback` has
|
|
80
|
+
* refused to try. That is the same failure as automations sitting on the
|
|
81
|
+
* internal endpoint: a name assumed once and never re-checked.
|
|
82
|
+
*
|
|
83
|
+
* The capability is still narrower than it sounds, and `restoreLatestOnly`
|
|
84
|
+
* below is what says so.
|
|
85
|
+
*/
|
|
86
|
+
restoreRepoSnapshot: has('restoreRepoSnapshot') || has('restoreRepoToSnapshot'),
|
|
87
|
+
restoreMutation: has('restoreRepoToSnapshot')
|
|
88
|
+
? 'restoreRepoToSnapshot'
|
|
89
|
+
: has('restoreRepoSnapshot')
|
|
90
|
+
? 'restoreRepoSnapshot'
|
|
91
|
+
: null,
|
|
92
|
+
/**
|
|
93
|
+
* A restore only accepts the **latest** uploaded snapshot. Measured: an
|
|
94
|
+
* older versionId is refused with "Version_id must reference the latest
|
|
95
|
+
* uploaded snapshot of the Pipe".
|
|
96
|
+
*
|
|
97
|
+
* That is the whole story for rollback on this endpoint, and it is why the
|
|
98
|
+
* pre-apply snapshot is usually not restorable: `pipe apply` reads the repo
|
|
99
|
+
* back to verify, and that read takes a snapshot of its own, which becomes
|
|
100
|
+
* the latest. Recovery stays forward-first.
|
|
101
|
+
*/
|
|
102
|
+
restoreLatestOnly: has('restoreRepoToSnapshot'),
|
|
103
|
+
createRepoDraft: has('createRepoDraft'),
|
|
104
|
+
importRepoSnapshot: has('importRepoSnapshot') || has('createRepoSnapshotFromPayload'),
|
|
105
|
+
mutations,
|
|
106
|
+
internalApi: internal?.status ?? 'unknown',
|
|
107
|
+
checkedAt: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
debug(
|
|
110
|
+
`capabilities: snapshots=${caps.snapshots} restore=${caps.restoreRepoSnapshot} ` +
|
|
111
|
+
`draft=${caps.createRepoDraft} import=${caps.importRepoSnapshot}`,
|
|
112
|
+
);
|
|
113
|
+
return caps;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const missingMutations = (c: Capabilities): string[] =>
|
|
117
|
+
Object.entries(c.mutations)
|
|
118
|
+
.filter(([, present]) => !present)
|
|
119
|
+
.map(([name]) => name);
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { ENDPOINTS, type TokenProvider } from '../config.ts';
|
|
2
|
+
import { debug } from '../util/log.ts';
|
|
3
|
+
import { retry } from '../util/pool.ts';
|
|
4
|
+
|
|
5
|
+
export type GqlError = { message: string; path?: unknown[]; extensions?: Record<string, unknown> };
|
|
6
|
+
|
|
7
|
+
export class GraphQLRequestError extends Error {
|
|
8
|
+
errors: GqlError[];
|
|
9
|
+
retryable: boolean;
|
|
10
|
+
status: number;
|
|
11
|
+
constructor(message: string, errors: GqlError[], status: number, retryable: boolean) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'GraphQLRequestError';
|
|
14
|
+
this.errors = errors;
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.retryable = retryable;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
21
|
+
|
|
22
|
+
/** Rate limiting and transient upstream faults are retryable; a bad query is not. */
|
|
23
|
+
const isRetryableMessage = (m: string) =>
|
|
24
|
+
/rate limit|too many requests|timeout|timed out|temporarily|try again|internal server error/i.test(m);
|
|
25
|
+
|
|
26
|
+
export type ClientOpts = {
|
|
27
|
+
/**
|
|
28
|
+
* A literal token, or a provider. The toolkit hands out short-lived OAuth
|
|
29
|
+
* access tokens, so a long apply needs to be able to ask for a fresh one —
|
|
30
|
+
* see src/config.ts.
|
|
31
|
+
*/
|
|
32
|
+
token: string | TokenProvider;
|
|
33
|
+
endpoint?: string;
|
|
34
|
+
label?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export class PipefyClient {
|
|
38
|
+
private readonly tokenProvider: TokenProvider;
|
|
39
|
+
readonly endpoint: string;
|
|
40
|
+
/** Every request, for `--stats` and for measuring the rate-limit ceiling. */
|
|
41
|
+
requestCount = 0;
|
|
42
|
+
retryCount = 0;
|
|
43
|
+
/** Times a 401 forced a credential refresh. Surfaced by `pipe doctor`. */
|
|
44
|
+
reauthCount = 0;
|
|
45
|
+
|
|
46
|
+
private typeFieldCache = new Map<string, Set<string> | null>();
|
|
47
|
+
private inputFieldCache = new Map<string, Set<string> | null>();
|
|
48
|
+
private requiredInputCache = new Map<string, Set<string>>();
|
|
49
|
+
private mutationInputTypeCache = new Map<string, string | null>();
|
|
50
|
+
|
|
51
|
+
constructor(opts: ClientOpts) {
|
|
52
|
+
this.tokenProvider = typeof opts.token === 'string' ? async () => opts.token as string : opts.token;
|
|
53
|
+
this.endpoint = opts.endpoint ?? ENDPOINTS.graphql;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The current bearer, refreshed only when something rejected the last one. */
|
|
57
|
+
async token(forceRefresh = false): Promise<string> {
|
|
58
|
+
if (forceRefresh) this.reauthCount++;
|
|
59
|
+
return this.tokenProvider({ forceRefresh });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async raw<T>(query: string, variables?: Record<string, unknown>, label?: string): Promise<T> {
|
|
63
|
+
let refreshed = false;
|
|
64
|
+
return retry(
|
|
65
|
+
async () => {
|
|
66
|
+
this.requestCount++;
|
|
67
|
+
const res = await fetch(this.endpoint, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: {
|
|
70
|
+
authorization: `Bearer ${await this.token()}`,
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
accept: 'application/json',
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify({ query, variables: variables ?? {} }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* An expired OAuth access token looks like a 401. Refresh once and let
|
|
79
|
+
* the retry loop send it again; a second 401 is a real permission
|
|
80
|
+
* problem and must not spin.
|
|
81
|
+
*/
|
|
82
|
+
if ((res.status === 401 || res.status === 403) && !refreshed) {
|
|
83
|
+
refreshed = true;
|
|
84
|
+
await this.token(true);
|
|
85
|
+
const err = new Error(`HTTP ${res.status} — credential refreshed, retrying`);
|
|
86
|
+
(err as { retryable?: boolean }).retryable = true;
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const text = await res.text();
|
|
91
|
+
let body: { data?: T; errors?: GqlError[] } = {};
|
|
92
|
+
try {
|
|
93
|
+
body = text ? (JSON.parse(text) as typeof body) : {};
|
|
94
|
+
} catch {
|
|
95
|
+
const retryable = RETRYABLE_STATUS.has(res.status);
|
|
96
|
+
throw new GraphQLRequestError(
|
|
97
|
+
`HTTP ${res.status} from ${this.endpoint}: ${text.slice(0, 200)}`,
|
|
98
|
+
[],
|
|
99
|
+
res.status,
|
|
100
|
+
retryable,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (body.errors?.length) {
|
|
105
|
+
const msg = body.errors.map((e) => e.message).join('; ');
|
|
106
|
+
const retryable = RETRYABLE_STATUS.has(res.status) || isRetryableMessage(msg);
|
|
107
|
+
throw new GraphQLRequestError(msg, body.errors, res.status, retryable);
|
|
108
|
+
}
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
throw new GraphQLRequestError(
|
|
111
|
+
`HTTP ${res.status} from ${this.endpoint}`,
|
|
112
|
+
[],
|
|
113
|
+
res.status,
|
|
114
|
+
RETRYABLE_STATUS.has(res.status),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (body.data === undefined || body.data === null) {
|
|
118
|
+
throw new GraphQLRequestError('GraphQL response had no data', [], res.status, false);
|
|
119
|
+
}
|
|
120
|
+
return body.data;
|
|
121
|
+
},
|
|
122
|
+
{ label: label ?? 'graphql' },
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A query whose failure is a fact about the endpoint, not an error. */
|
|
127
|
+
async tryRaw<T>(query: string, variables?: Record<string, unknown>, label?: string): Promise<T | null> {
|
|
128
|
+
try {
|
|
129
|
+
return await this.raw<T>(query, variables, label);
|
|
130
|
+
} catch (e) {
|
|
131
|
+
debug(`probe failed (${label ?? 'unnamed'}): ${(e as Error).message}`);
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Which fields a type actually has.
|
|
138
|
+
*
|
|
139
|
+
* The reconstruction path (reconstruct.ts) asks for columns that PLAN.md marks
|
|
140
|
+
* unverified. Rather than sending a query that fails wholesale on one unknown
|
|
141
|
+
* field, we introspect once and ask only for what exists. One cheap query
|
|
142
|
+
* buys a read path that degrades field-by-field instead of all-or-nothing.
|
|
143
|
+
*/
|
|
144
|
+
async typeFields(typeName: string): Promise<Set<string> | null> {
|
|
145
|
+
if (this.typeFieldCache.has(typeName)) return this.typeFieldCache.get(typeName) ?? null;
|
|
146
|
+
const data = await this.tryRaw<{ __type: { fields: Array<{ name: string }> | null } | null }>(
|
|
147
|
+
'query($n:String!){ __type(name:$n){ fields{ name } } }',
|
|
148
|
+
{ n: typeName },
|
|
149
|
+
`introspect ${typeName}`,
|
|
150
|
+
);
|
|
151
|
+
const fields = data?.__type?.fields;
|
|
152
|
+
const set = fields ? new Set(fields.map((f) => f.name)) : null;
|
|
153
|
+
this.typeFieldCache.set(typeName, set);
|
|
154
|
+
return set;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether a mutation exists — the basis of capability detection (PLAN.md §1.2). */
|
|
158
|
+
async hasMutation(name: string): Promise<boolean> {
|
|
159
|
+
const fields = await this.typeFields('Mutation');
|
|
160
|
+
if (!fields) return false;
|
|
161
|
+
return fields.has(name);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The named type of a mutation's `input` argument.
|
|
166
|
+
*
|
|
167
|
+
* Never guessed. The registry documents traps like `createFieldConditionInput`
|
|
168
|
+
* being lowercase-c while `UpdateFieldConditionInput` is not; asking the schema
|
|
169
|
+
* removes a whole class of "mutation exists, input type name wrong" failures.
|
|
170
|
+
*/
|
|
171
|
+
async mutationInputType(mutation: string): Promise<string | null> {
|
|
172
|
+
if (this.mutationInputTypeCache.has(mutation)) return this.mutationInputTypeCache.get(mutation) ?? null;
|
|
173
|
+
const data = await this.tryRaw<{
|
|
174
|
+
__type: {
|
|
175
|
+
fields: Array<{
|
|
176
|
+
name: string;
|
|
177
|
+
args: Array<{ name: string; type: TypeRef }>;
|
|
178
|
+
}> | null;
|
|
179
|
+
} | null;
|
|
180
|
+
}>(
|
|
181
|
+
`query {
|
|
182
|
+
__type(name: "Mutation") {
|
|
183
|
+
fields {
|
|
184
|
+
name
|
|
185
|
+
args { name type { kind name ofType { kind name ofType { kind name } } } }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}`,
|
|
189
|
+
undefined,
|
|
190
|
+
'introspect Mutation args',
|
|
191
|
+
);
|
|
192
|
+
const field = data?.__type?.fields?.find((f) => f.name === mutation);
|
|
193
|
+
const arg = field?.args.find((a) => a.name === 'input') ?? field?.args[0];
|
|
194
|
+
const named = arg ? unwrapTypeName(arg.type) : null;
|
|
195
|
+
this.mutationInputTypeCache.set(mutation, named);
|
|
196
|
+
return named;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Input fields that are NON_NULL with no default — the ones a request must
|
|
201
|
+
* carry a value for.
|
|
202
|
+
*
|
|
203
|
+
* CreatePipeRelationInput makes every boolean required, including
|
|
204
|
+
* autoFillFieldEnabled, which the snapshot payload has no column for at all.
|
|
205
|
+
* Sent without it the mutation is rejected mid-apply; knowing up front turns
|
|
206
|
+
* that into something `pipe plan` can say before anything is written.
|
|
207
|
+
*/
|
|
208
|
+
async requiredInputFields(typeName: string): Promise<Set<string>> {
|
|
209
|
+
const cached = this.requiredInputCache.get(typeName);
|
|
210
|
+
if (cached) return cached;
|
|
211
|
+
const data = await this.tryRaw<{
|
|
212
|
+
__type: { inputFields: Array<{ name: string; defaultValue: string | null; type: { kind: string } }> | null } | null;
|
|
213
|
+
}>(
|
|
214
|
+
'query($n:String!){ __type(name:$n){ inputFields { name defaultValue type { kind } } } }',
|
|
215
|
+
{ n: typeName },
|
|
216
|
+
`introspect required ${typeName}`,
|
|
217
|
+
);
|
|
218
|
+
const req = new Set<string>();
|
|
219
|
+
for (const fld of data?.__type?.inputFields ?? []) {
|
|
220
|
+
if (fld.type.kind === 'NON_NULL' && fld.defaultValue === null) req.add(fld.name);
|
|
221
|
+
}
|
|
222
|
+
this.requiredInputCache.set(typeName, req);
|
|
223
|
+
return req;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The fields an input object type accepts. */
|
|
227
|
+
async inputFields(typeName: string): Promise<Set<string> | null> {
|
|
228
|
+
if (this.inputFieldCache.has(typeName)) return this.inputFieldCache.get(typeName) ?? null;
|
|
229
|
+
const data = await this.tryRaw<{ __type: { inputFields: Array<{ name: string }> | null } | null }>(
|
|
230
|
+
'query($n:String!){ __type(name:$n){ inputFields{ name } } }',
|
|
231
|
+
{ n: typeName },
|
|
232
|
+
`introspect input ${typeName}`,
|
|
233
|
+
);
|
|
234
|
+
const fields = data?.__type?.inputFields;
|
|
235
|
+
const set = fields ? new Set(fields.map((f) => f.name)) : null;
|
|
236
|
+
this.inputFieldCache.set(typeName, set);
|
|
237
|
+
return set;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Filter a wanted selection down to what the type has. `selection` entries may
|
|
242
|
+
* be a bare field (`"label"`) or a field with a sub-selection
|
|
243
|
+
* (`["connectedRepo", "{ id name }"]`).
|
|
244
|
+
*/
|
|
245
|
+
async prune(typeName: string, selection: Array<string | [string, string]>): Promise<string> {
|
|
246
|
+
const have = await this.typeFields(typeName);
|
|
247
|
+
const keep: string[] = [];
|
|
248
|
+
for (const s of selection) {
|
|
249
|
+
const [name, sub] = Array.isArray(s) ? s : [s, ''];
|
|
250
|
+
if (have && !have.has(name)) {
|
|
251
|
+
debug(`skipping ${typeName}.${name} — not in schema`);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
keep.push(sub ? `${name} ${sub}` : name);
|
|
255
|
+
}
|
|
256
|
+
return keep.join('\n ');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type TypeRef = { kind: string; name: string | null; ofType?: TypeRef | null };
|
|
261
|
+
|
|
262
|
+
/** Unwrap NON_NULL / LIST wrappers down to the named type. */
|
|
263
|
+
const unwrapTypeName = (t: TypeRef | null | undefined): string | null => {
|
|
264
|
+
let cur: TypeRef | null | undefined = t;
|
|
265
|
+
while (cur && !cur.name) cur = cur.ofType ?? null;
|
|
266
|
+
return cur?.name ?? null;
|
|
267
|
+
};
|