@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,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
+ };