@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,775 @@
1
+ import { LIMITS } from '../config.ts';
2
+ import { debug, progress, progressDone, step, warn } from '../util/log.ts';
3
+ import { mapPool } from '../util/pool.ts';
4
+ import {
5
+ ENTITY_NAMES,
6
+ emptyRelations,
7
+ fullCoverage,
8
+ type Coverage,
9
+ type EntityName,
10
+ type PayloadEnvelope,
11
+ type RepoPayload,
12
+ type Row,
13
+ } from '../model/payload.ts';
14
+ import type { PipefyClient } from './client.ts';
15
+ import type { InternalApi } from './internal.ts';
16
+
17
+ /**
18
+ * Reconstruct a snapshot-shaped payload from the public GraphQL API plus the
19
+ * internal API — the read path for organizations without snapshots.
20
+ *
21
+ * Every query here is taken from the Change Migrator recipes, which do exactly
22
+ * this in production. What the recipes read, we read; what they cannot see, we
23
+ * mark `unknown` rather than empty, so a later pack or diff never reads absence
24
+ * as deletion.
25
+ *
26
+ * Field names the plan lists as unverified are requested through introspection
27
+ * pruning (client.prune), so an unknown column costs us that column instead of
28
+ * the whole query.
29
+ */
30
+
31
+ type Spec = {
32
+ /** Field name on the GraphQL type. */
33
+ gql: string;
34
+ /** Sub-selection, for object-valued fields. */
35
+ sub?: string;
36
+ /** Column name in the snapshot payload. */
37
+ to: string;
38
+ /** Pull the payload value out of the GraphQL value. */
39
+ pick?: (v: unknown) => unknown;
40
+ };
41
+
42
+ const firstId = (v: unknown): unknown => {
43
+ if (v && typeof v === 'object' && 'id' in (v as Row)) return (v as Row)['id'];
44
+ return v;
45
+ };
46
+
47
+ const numOrNull = (v: unknown): number | null => {
48
+ if (v === null || v === undefined || v === '') return null;
49
+ const n = Number(v);
50
+ return Number.isFinite(n) ? n : null;
51
+ };
52
+
53
+ /** Build a pruned selection plus the mapper that turns the result into a payload row. */
54
+ const build = async (client: PipefyClient, typeName: string, specs: Spec[]) => {
55
+ const have = await client.typeFields(typeName);
56
+ const kept = specs.filter((s) => !have || have.has(s.gql));
57
+ const skipped = specs.filter((s) => have && !have.has(s.gql)).map((s) => s.gql);
58
+ if (skipped.length) debug(`${typeName}: not in schema — ${skipped.join(', ')}`);
59
+ const selection = kept.map((s) => (s.sub ? `${s.gql} ${s.sub}` : s.gql)).join('\n ');
60
+ const map = (src: Row): Row => {
61
+ const row: Row = {};
62
+ for (const s of kept) {
63
+ if (!(s.gql in src)) continue;
64
+ row[s.to] = s.pick ? s.pick(src[s.gql]) : src[s.gql];
65
+ }
66
+ return row;
67
+ };
68
+ return { selection, map, skipped, missing: skipped.length > 0 };
69
+ };
70
+
71
+ /** The API returns `settings` as a JSON string; the payload stores the object. */
72
+ /** An object whose every value is null carries no more meaning than null. */
73
+ const emptyToNull = (v: unknown): unknown => {
74
+ if (v === null || v === undefined) return null;
75
+ if (Array.isArray(v)) return v.length ? v : null;
76
+ if (typeof v === 'object') {
77
+ const values = Object.values(v as Row);
78
+ return values.length && values.some((x) => x !== null && x !== undefined) ? v : null;
79
+ }
80
+ return v;
81
+ };
82
+
83
+ /** The payload stores "no value" here as {}, the API as [] or null. */
84
+ const emptyToObject = (v: unknown): unknown => {
85
+ if (v === null || v === undefined) return {};
86
+ if (Array.isArray(v)) return v.length ? v : {};
87
+ if (typeof v === 'object' && Object.keys(v as Row).length === 0) return {};
88
+ return v;
89
+ };
90
+
91
+ const parseJsonish = (v: unknown): unknown => {
92
+ if (typeof v !== 'string') return v;
93
+ try {
94
+ return JSON.parse(v);
95
+ } catch {
96
+ return v;
97
+ }
98
+ };
99
+
100
+ const PIPE_SPECS: Spec[] = [
101
+ { gql: 'id', to: 'id', pick: numOrNull },
102
+ { gql: 'name', to: 'name' },
103
+ { gql: 'uuid', to: 'uuid' },
104
+ { gql: 'noun', to: 'noun' },
105
+ { gql: 'icon', to: 'icon' },
106
+ // The payload's colour column is `color_uuid`, which the API does not expose;
107
+ // `color` is a different representation of the same thing, so it is metadata.
108
+ { gql: 'color', to: '_color_name' },
109
+ { gql: 'public', to: 'public' },
110
+ { gql: 'description', to: 'description' },
111
+ { gql: 'expiration_time', to: 'expiration_time' },
112
+ { gql: 'create_card_label', to: 'create_card_label' },
113
+ { gql: 'anyone_can_create_card', to: 'anyone_can_create_card' },
114
+ { gql: 'only_admin_can_remove_cards', to: 'only_admin_can_remove_cards' },
115
+ { gql: 'only_assignees_can_edit_cards', to: 'only_assignees_can_edit_cards' },
116
+ { gql: 'count_only_week_days', to: 'count_only_week_days' },
117
+ { gql: 'suid', to: 'suid' },
118
+ { gql: 'cards_count', to: 'cards_count' },
119
+ { gql: 'users_count', to: 'users_count' },
120
+ { gql: 'organizationId', to: 'organization_id', pick: numOrNull },
121
+ { gql: 'organization', sub: '{ id uuid }', to: 'organization_uuid', pick: (v) => (v as Row | null)?.['uuid'] ?? null },
122
+ { gql: 'title_field', sub: '{ id internal_id }', to: 'title_field_id', pick: (v) => numOrNull((v as Row | null)?.['internal_id'] ?? null) },
123
+ { gql: 'startFormPhaseId', to: '_start_form_phase_id' },
124
+ ];
125
+
126
+ /**
127
+ * Verified against api.pipefy.com by introspection, not assumed.
128
+ *
129
+ * `next_phase_ids` is public, but it is a *forward-only* view of what the
130
+ * payload calls `phase_jumps` — see readPhaseJumps for the measurement. It is
131
+ * read here so it can serve as a marked-partial fallback, never as the primary
132
+ * source.
133
+ */
134
+ const PHASE_SPECS: Spec[] = [
135
+ { gql: 'id', to: 'id', pick: numOrNull },
136
+ { gql: 'name', to: 'name' },
137
+ { gql: 'uuid', to: 'uuid' },
138
+ { gql: 'done', to: 'done' },
139
+ { gql: 'description', to: 'description' },
140
+ { gql: 'lateness_time', to: 'lateness_time' },
141
+ { gql: 'index', to: 'index' },
142
+ { gql: 'can_receive_card_directly_from_draft', to: 'can_receive_card_directly_from_draft' },
143
+ { gql: 'identifyTask', to: 'identify_task' },
144
+ { gql: 'custom_sorting_preferences', to: 'custom_sorting_preferences' },
145
+ { gql: 'cards_count', to: 'cards_count' },
146
+ { gql: 'next_phase_ids', to: '_next_phase_ids' },
147
+ ];
148
+
149
+ /**
150
+ * The type is `PhaseField`, not `Field` — pruning against a type name that does
151
+ * not exist filters nothing, so the query goes out with every speculative
152
+ * column and fails wholesale. That is how this list came to be verified.
153
+ *
154
+ * `internal_id` is the numeric id the snapshot payload calls `id`, and the
155
+ * public `id` is the label-derived slug. Getting this backwards would make every
156
+ * field reference in every automation meaningless — recipe steps 46/47 key their
157
+ * whole dev→prod map on `internal_id`.
158
+ *
159
+ * Several payload columns are camelCase here and there is no `unique` at all.
160
+ */
161
+ const FIELD_SPECS: Spec[] = [
162
+ { gql: 'internal_id', to: 'id', pick: numOrNull },
163
+ { gql: 'id', to: 'slug' },
164
+ { gql: 'uuid', to: 'uuid' },
165
+ { gql: 'label', to: 'label' },
166
+ { gql: 'type', to: 'type_id' },
167
+ { gql: 'options', to: 'options' },
168
+ { gql: 'description', to: 'description' },
169
+ { gql: 'help', to: 'help' },
170
+ { gql: 'required', to: 'required' },
171
+ { gql: 'editable', to: 'editable' },
172
+ { gql: 'minimal_view', to: 'minimal_view' },
173
+ { gql: 'custom_validation', to: 'custom_validation' },
174
+ { gql: 'index', to: 'index' },
175
+ { gql: 'is_multiple', to: '_is_multiple' },
176
+ { gql: 'settings', to: 'settings', pick: parseJsonish },
177
+ { gql: 'synced_with_card', to: 'card_synced' },
178
+ { gql: 'archived', to: '_archived' },
179
+ { gql: 'connectedRepo', sub: '{ __typename ... on PublicPipe { id } ... on PublicTable { id internal_id } }', to: 'connected_pipe_id', pick: (v) => connectedRepoId(v) },
180
+ { gql: 'canCreateNewConnected', to: 'can_create_connected_cards' },
181
+ { gql: 'canConnectExisting', to: 'can_search_connected_cards' },
182
+ { gql: 'canConnectMultiples', to: 'can_connect_multiple_cards' },
183
+ { gql: 'childMustExistToFinishParent', to: 'child_must_exist_to_finish_parent' },
184
+ { gql: 'allChildrenMustBeDoneToMoveParent', to: 'all_children_must_be_done_to_move_parent' },
185
+ { gql: 'allChildrenMustBeDoneToFinishParent', to: 'all_children_must_be_done_to_finish_parent' },
186
+ ];
187
+
188
+ /**
189
+ * A connector's target, as the numeric id the payload uses. A connected pipe
190
+ * answers with its numeric `id`; a connected database answers with its slug and
191
+ * carries the numeric form in `internal_id` (PLAN.md §2.2, id duality).
192
+ */
193
+ const connectedRepoId = (v: unknown): unknown => {
194
+ if (!v || typeof v !== 'object') return null;
195
+ const repo = v as Row;
196
+ return numOrNull(repo['internal_id'] ?? repo['id']);
197
+ };
198
+
199
+ export type ReconstructResult = PayloadEnvelope;
200
+
201
+ export type ReconstructOpts = {
202
+ /** Organization id, required by the internal automations query. */
203
+ organizationId?: string | null;
204
+ /** Skip the internal API entirely (automations + phase jumps become unknown). */
205
+ noInternal?: boolean;
206
+ };
207
+
208
+ export class Reconstructor {
209
+ private client: PipefyClient;
210
+ private internal: InternalApi;
211
+ /** Decided once on the first automation, then reused. */
212
+ private richAutomationParams: boolean | null = null;
213
+ /** Public forward-only jump sets, kept as a fallback for phase_jumps. */
214
+ private publicNextPhaseIds = new Map<number, number[]>();
215
+
216
+ constructor(client: PipefyClient, internal: InternalApi) {
217
+ this.client = client;
218
+ this.internal = internal;
219
+ }
220
+
221
+ async reconstruct(repoId: string | number, opts: ReconstructOpts = {}): Promise<ReconstructResult> {
222
+ const coverage = fullCoverage('unknown');
223
+ const notes: string[] = [];
224
+ const relations = emptyRelations();
225
+
226
+ const mark = (n: EntityName, c: Coverage) => {
227
+ coverage[n] = c;
228
+ };
229
+
230
+ step(`reading pipe ${repoId} over GraphQL (no snapshot)`);
231
+
232
+ // ── root + phases + fields ───────────────────────────────────────────────
233
+ const [pipe, phase, field] = await Promise.all([
234
+ build(this.client, 'Pipe', PIPE_SPECS),
235
+ build(this.client, 'Phase', PHASE_SPECS),
236
+ build(this.client, 'PhaseField', FIELD_SPECS),
237
+ ]);
238
+
239
+ const data = await this.client.raw<{ pipe: Row | null }>(
240
+ `query($id: ID!) {
241
+ pipe(id: $id) {
242
+ ${pipe.selection}
243
+ phases {
244
+ ${phase.selection}
245
+ fields {
246
+ ${field.selection}
247
+ }
248
+ }
249
+ start_form_fields {
250
+ ${field.selection}
251
+ }
252
+ }
253
+ }`,
254
+ { id: String(repoId) },
255
+ 'reconstruct pipe',
256
+ );
257
+
258
+ const src = data.pipe;
259
+ if (!src) throw new Error(`pipe ${repoId} not found, or the token cannot read it`);
260
+
261
+ const root = pipe.map(src);
262
+ const startFormPhaseId = numOrNull(root['_start_form_phase_id']);
263
+ delete root['_start_form_phase_id'];
264
+ root['id'] = Number(root['id'] ?? repoId);
265
+ if (pipe.missing) notes.push(`pipe columns not in this schema: ${pipe.skipped.join(', ')}`);
266
+
267
+ const gqlPhases = (src['phases'] as Row[] | undefined) ?? [];
268
+ const startFormFields = (src['start_form_fields'] as Row[] | undefined) ?? [];
269
+
270
+ /**
271
+ * The start form is a phase (PLAN.md §4, at index 0). The public
272
+ * API splits it out as `start_form_fields`, so we put it back — and only
273
+ * synthesise a phase row for it when `phases` does not already include it.
274
+ */
275
+ const phaseRows: Row[] = [];
276
+ const haveStartFormPhase = gqlPhases.some((p) => numOrNull(p['id']) === startFormPhaseId);
277
+ if (startFormPhaseId !== null && !haveStartFormPhase) {
278
+ phaseRows.push({ id: startFormPhaseId, name: 'Start form', index: 0, done: false, repo_id: root['id'] });
279
+ }
280
+ for (const p of gqlPhases) phaseRows.push({ ...phase.map(p), repo_id: root['id'] });
281
+
282
+ // Public phases come back in order; the payload's `index` is authoritative
283
+ // when present, otherwise array order is the only truth we have.
284
+ const anyIndex = phaseRows.some((p) => typeof p['index'] === 'number');
285
+ phaseRows.sort((a, b) => {
286
+ if (anyIndex) return Number(a['index'] ?? 0) - Number(b['index'] ?? 0);
287
+ return numOrNull(a['id'])! - numOrNull(b['id'])!;
288
+ });
289
+ phaseRows.forEach((p, i) => {
290
+ if (!anyIndex) p['index'] = i;
291
+ });
292
+ relations.phases = phaseRows;
293
+ mark('phases', phase.missing ? 'partial' : 'complete');
294
+
295
+ /**
296
+ * `Phase.next_phase_ids` is public, but it is **not** the payload's
297
+ * `phase_jumps`. Checked against a snapshot of the same pipe:
298
+ *
299
+ * payload phase_jumps 1,2,3,1,2,2,2,2,1 targets per phase
300
+ * next_phase_ids 0,1,1,0,1,1,1,1,0
301
+ *
302
+ * The payload merges forward *and backward* jumps into one array; the public
303
+ * field carries the forward set only. Mapping it straight onto `phase_jumps`
304
+ * produced a tree that, on apply, would have deleted every backward jump —
305
+ * so it is kept only as a fallback, and marked `partial` when used.
306
+ */
307
+ const publicNext = new Map<number, number[]>();
308
+ for (const p of phaseRows) {
309
+ const targets = p['_next_phase_ids'];
310
+ delete p['_next_phase_ids'];
311
+ const id = numOrNull(p['id']);
312
+ if (id !== null && Array.isArray(targets)) {
313
+ publicNext.set(id, targets.map(numOrNull).filter((t): t is number => t !== null));
314
+ }
315
+ }
316
+ this.publicNextPhaseIds = publicNext;
317
+
318
+ const fieldRows: Row[] = [];
319
+ const pushFields = (rows: Row[], phaseId: number | null) => {
320
+ rows.forEach((f, i) => {
321
+ const row = field.map(f);
322
+ row['phase_id'] = phaseId;
323
+ row['repo_id'] = root['id'];
324
+ if (row['index'] === undefined) row['index'] = i;
325
+
326
+ /**
327
+ * The payload records archiving as a timestamp (`archived_at`); the API
328
+ * exposes a boolean. The distinction matters because the diff must tell
329
+ * archive from delete or it destroys data when the FDE meant to hide a
330
+ * field (PLAN.md §9) — so the flag is carried across, and the timestamp
331
+ * is left null rather than invented.
332
+ */
333
+ if ('_archived' in row) {
334
+ const archived = row['_archived'] === true;
335
+ delete row['_archived'];
336
+ row['archived_at'] = archived ? (row['archived_at'] ?? null) : null;
337
+ if (archived) row['_archived'] = true;
338
+ }
339
+ // A connector field's target is the discovery edge PLAN.md §2.2 depends on.
340
+ if (row['connected_pipe_id'] !== undefined) row['connected_pipe_id'] = numOrNull(row['connected_pipe_id']);
341
+ fieldRows.push(row);
342
+ });
343
+ };
344
+ pushFields(startFormFields, startFormPhaseId);
345
+ for (const p of gqlPhases) pushFields((p['fields'] as Row[] | undefined) ?? [], numOrNull(p['id']));
346
+ relations.fields = fieldRows;
347
+ mark('fields', field.missing ? 'partial' : 'complete');
348
+ if (field.missing) notes.push(`field columns not in this schema: ${field.skipped.join(', ')}`);
349
+
350
+ // ── labels ──────────────────────────────────────────────────────────────
351
+ const labels = await this.client.tryRaw<{ pipe: { labels: Row[] | null } | null }>(
352
+ `query($id: ID!) { pipe(id: $id) { labels { id name color } } }`,
353
+ { id: String(repoId) },
354
+ 'labels',
355
+ );
356
+ if (labels?.pipe?.labels) {
357
+ relations.labels = labels.pipe.labels.map((l) => ({
358
+ id: numOrNull(l['id']),
359
+ name: l['name'],
360
+ color: l['color'],
361
+ repo_id: root['id'],
362
+ }));
363
+ // No `uuid` over the public API, and uuid is how labels are identified in
364
+ // a snapshot — so this is partial, not complete.
365
+ mark('labels', 'partial');
366
+ } else {
367
+ notes.push('labels: not readable over this endpoint');
368
+ }
369
+
370
+ // ── webhooks ────────────────────────────────────────────────────────────
371
+ const wh = await this.client.tryRaw<{ pipe: { webhooks: Row[] | null } | null }>(
372
+ `query($id: ID!) { pipe(id: $id) { webhooks { id name url email actions headers } } }`,
373
+ { id: String(repoId) },
374
+ 'webhooks',
375
+ );
376
+ if (wh?.pipe?.webhooks) {
377
+ relations.webhooks = wh.pipe.webhooks.map((w) => ({
378
+ id: numOrNull(w['id']),
379
+ name: w['name'],
380
+ url: w['url'],
381
+ email: w['email'] ?? null,
382
+ actions: w['actions'] ?? [],
383
+ // Returned as a JSON string, stored as an object — same as field settings.
384
+ headers: parseJsonish(w['headers']) ?? {},
385
+ repo_id: root['id'],
386
+ }));
387
+ mark('webhooks', 'complete');
388
+ } else {
389
+ notes.push('webhooks: not readable over this endpoint');
390
+ }
391
+
392
+ // ── pipe_relations ──────────────────────────────────────────────────────
393
+ const prSel = await this.client.prune('Pipe', [
394
+ ['pipe_relations', '{ id name parent { id } child { id } canCreateNewItems canConnectExistingItems canConnectMultipleItems childMustExistToMoveParent childMustExistToFinishParent allChildrenMustBeDoneToMoveParent allChildrenMustBeDoneToFinishParent }'],
395
+ ]);
396
+ if (prSel) {
397
+ const pr = await this.client.tryRaw<{ pipe: { pipe_relations: Row[] | null } | null }>(
398
+ `query($id: ID!) { pipe(id: $id) { ${prSel} } }`,
399
+ { id: String(repoId) },
400
+ 'pipe_relations',
401
+ );
402
+ if (pr?.pipe?.pipe_relations) {
403
+ relations.pipe_relations = pr.pipe.pipe_relations.map((r) => ({
404
+ id: numOrNull(r['id']),
405
+ name: r['name'],
406
+ parent_id: numOrNull(firstId(r['parent'])),
407
+ child_id: numOrNull(firstId(r['child'])),
408
+ can_create_new_items: r['canCreateNewItems'],
409
+ can_connect_existing_items: r['canConnectExistingItems'],
410
+ can_connect_multiple_items: r['canConnectMultipleItems'],
411
+ child_must_exist_to_move_parent: r['childMustExistToMoveParent'],
412
+ child_must_exist_to_finish_parent: r['childMustExistToFinishParent'],
413
+ all_children_must_be_done_to_move_parent: r['allChildrenMustBeDoneToMoveParent'],
414
+ all_children_must_be_done_to_finish_parent: r['allChildrenMustBeDoneToFinishParent'],
415
+ }));
416
+ mark('pipe_relations', 'partial');
417
+ }
418
+ }
419
+
420
+ // ── field conditions (+ actions, condition, expressions) ────────────────
421
+ await this.readFieldConditions(repoId, root, relations, mark, notes);
422
+
423
+ // ── automations (+ conditions, expressions, field_maps) ─────────────────
424
+ if (opts.noInternal) {
425
+ notes.push('automations and phase jumps skipped (--no-internal)');
426
+ } else {
427
+ await this.readAutomations(repoId, root, relations, mark, notes, opts.organizationId ?? null);
428
+ await this.readPhaseJumps(relations, mark, notes);
429
+ }
430
+
431
+ for (const n of ['public_forms', 'repo_preferences', 'visibilities', 'email_templates', 'email_inboxes'] as EntityName[]) {
432
+ if (coverage[n] === 'unknown') {
433
+ notes.push(`${n}: no read path outside a snapshot — left unknown, never treated as empty`);
434
+ }
435
+ }
436
+
437
+ /**
438
+ * No reconstructed group is ever 'complete'.
439
+ *
440
+ * Row coverage and *column* coverage are different things: the public API
441
+ * returns every phase, but not `color_uuid`, `only_admin_can_move_to_previous`
442
+ * or `repo_id`. Marking such a group complete made a snapshot-vs-reconstruction
443
+ * diff report ten phantom changes per phase, each one a proposal to null a
444
+ * column that had simply not been read. 'partial' is the honest ceiling, and it
445
+ * is what tells the diff to treat absence here as no information.
446
+ */
447
+ for (const name of ENTITY_NAMES) {
448
+ if (coverage[name] === 'complete') coverage[name] = 'partial';
449
+ }
450
+
451
+ const payload: RepoPayload = { ...root, id: Number(root['id']), relations };
452
+ return {
453
+ source: 'reconstructed',
454
+ versionId: null,
455
+ readAt: new Date().toISOString(),
456
+ repoKind: 'pipe',
457
+ coverage,
458
+ repo_columns: pipe.missing ? 'partial' : 'complete',
459
+ notes,
460
+ payload,
461
+ };
462
+ }
463
+
464
+ /**
465
+ * Recipe `Pipe Data Treatment` steps 49/50, split into the four payload
466
+ * arrays a snapshot would have produced.
467
+ */
468
+ private async readFieldConditions(
469
+ repoId: string | number,
470
+ root: Row,
471
+ relations: ReturnType<typeof emptyRelations>,
472
+ mark: (n: EntityName, c: Coverage) => void,
473
+ notes: string[],
474
+ ) {
475
+ const q = `query($id: ID!) {
476
+ pipe(id: $id) {
477
+ fieldConditions {
478
+ id
479
+ name
480
+ phase { id }
481
+ actions {
482
+ actionId
483
+ whenEvaluator
484
+ phaseField { internal_id label }
485
+ }
486
+ condition {
487
+ expressions_structure
488
+ expressions { field_address operation value structure_id }
489
+ }
490
+ }
491
+ }
492
+ }`;
493
+ const data = await this.client.tryRaw<{ pipe: { fieldConditions: Row[] | null } | null }>(
494
+ q,
495
+ { id: String(repoId) },
496
+ 'fieldConditions',
497
+ );
498
+ const fcs = data?.pipe?.fieldConditions;
499
+ if (!fcs) {
500
+ notes.push('field_conditions: pipe.fieldConditions not readable — left unknown');
501
+ return;
502
+ }
503
+
504
+ fcs.forEach((fc, i) => {
505
+ const fcId = numOrNull(fc['id']);
506
+ relations.field_conditions.push({
507
+ id: fcId,
508
+ name: fc['name'],
509
+ phase_id: numOrNull(firstId(fc['phase'])),
510
+ index: i + 1,
511
+ repo_id: root['id'],
512
+ });
513
+
514
+ for (const a of (fc['actions'] as Row[] | undefined) ?? []) {
515
+ relations.field_condition_actions.push({
516
+ field_condition_id: fcId,
517
+ action_id: a['actionId'],
518
+ when_evaluator: a['whenEvaluator'],
519
+ field_id: numOrNull((a['phaseField'] as Row | null)?.['internal_id'] ?? null),
520
+ phase_id: null,
521
+ });
522
+ }
523
+
524
+ const cond = fc['condition'] as Row | null;
525
+ if (cond) {
526
+ relations.conditions.push({
527
+ source_type: 'FieldCondition',
528
+ source_id: fcId,
529
+ expressions_structure: cond['expressions_structure'] ?? [],
530
+ });
531
+ for (const e of (cond['expressions'] as Row[] | undefined) ?? []) {
532
+ const addr = e['field_address'];
533
+ relations.condition_expressions.push({
534
+ /** condition_id is resolved at pack time from the owner. */
535
+ condition_id: null,
536
+ _condition_owner: { source_type: 'FieldCondition', source_id: fcId },
537
+ field_address: addr,
538
+ field_id: numOrNull(addr),
539
+ operation: e['operation'],
540
+ value: e['value'],
541
+ structure_id: numOrNull(e['structure_id']),
542
+ });
543
+ }
544
+ }
545
+ });
546
+ mark('field_conditions', 'complete');
547
+ mark('field_condition_actions', 'complete');
548
+ mark('conditions', 'partial');
549
+ mark('condition_expressions', 'partial');
550
+ }
551
+
552
+ /**
553
+ * Recipe: list over the internal `getAutomations`, then `loadAutomationToEdit`
554
+ * per automation. The list alone omits `action_params`, so there is no
555
+ * shortcut — this is N+1 by necessity, bounded by readConcurrency.
556
+ */
557
+ private async readAutomations(
558
+ repoId: string | number,
559
+ root: Row,
560
+ relations: ReturnType<typeof emptyRelations>,
561
+ mark: (n: EntityName, c: Coverage) => void,
562
+ notes: string[],
563
+ organizationId: string | null,
564
+ ) {
565
+ const orgId = organizationId ?? (root['organization_id'] ? String(root['organization_id']) : null);
566
+ if (!orgId) {
567
+ notes.push('automations: no organization id available for the internal automations query');
568
+ return;
569
+ }
570
+
571
+ const list = await this.internal.listAutomations(orgId, repoId);
572
+ if (!list) {
573
+ notes.push('automations: internal API unavailable — left unknown, never treated as empty (PLAN.md §2.1)');
574
+ return;
575
+ }
576
+ if (list.length === 0) {
577
+ mark('automations', 'complete');
578
+ return;
579
+ }
580
+
581
+ let failures = 0;
582
+ const details = await mapPool(list, LIMITS.readConcurrency, async (a, i) => {
583
+ progress(`automation ${i + 1}/${list.length}: ${String(a['name'] ?? a['id'])}`);
584
+ const full = await this.internal.loadAutomation(String(a['id']));
585
+ if (!full) failures++;
586
+ return { summary: a, full };
587
+ });
588
+ progressDone();
589
+
590
+ for (const { summary, full } of details) {
591
+ const id = numOrNull(summary['id']);
592
+ if (!full) {
593
+ // Recording the automation without its params is honest; silently
594
+ // dropping it would let a later diff propose deleting it.
595
+ relations.automations.push({
596
+ id,
597
+ name: summary['name'],
598
+ event_id: summary['event_id'],
599
+ action_id: summary['action_id'],
600
+ active: summary['active'],
601
+ event_repo_id: numOrNull(firstId(summary['event_repo'])),
602
+ action_repo_id: numOrNull(firstId(summary['action_repo'])),
603
+ _incomplete: true,
604
+ });
605
+ continue;
606
+ }
607
+
608
+ const ep = (full['event_params'] as Row | null) ?? {};
609
+ const ap = (full['action_params'] as Row | null) ?? {};
610
+
611
+ relations.automations.push({
612
+ id,
613
+ name: full['name'],
614
+ event_id: full['event_id'],
615
+ action_id: full['action_id'],
616
+ active: full['active'],
617
+ event_repo_id: numOrNull(firstId(full['event_repo'])),
618
+ action_repo_id: numOrNull(firstId(full['action_repo'])),
619
+ organization_id: numOrNull(orgId),
620
+ scheduler_frequency: full['scheduler_frequency'] ?? null,
621
+ // An all-null cron object means "no schedule", which the payload stores
622
+ // as null; an empty searchFor list means "no filter", stored as {}.
623
+ scheduler_cron: emptyToNull(full['schedulerCron']),
624
+ search_for: emptyToObject(full['searchFor']),
625
+ response_schema: full['responseSchema'] ?? null,
626
+ event_params: snakeParams(ep),
627
+ action_params: snakeParams(ap),
628
+ });
629
+
630
+ for (const fm of ((ap['fieldMap'] as Row[] | undefined) ?? [])) {
631
+ relations.field_maps.push({
632
+ owner_type: 'Automation',
633
+ owner_id: id,
634
+ field_id: numOrNull(fm['fieldId']),
635
+ value: fm['value'] ?? '',
636
+ input_mode: fm['inputMode'] ?? null,
637
+ });
638
+ }
639
+
640
+ const cond = full['condition'] as Row | null;
641
+ if (cond && ((cond['expressions'] as Row[] | undefined)?.length || cond['expressions_structure'])) {
642
+ relations.conditions.push({
643
+ source_type: 'Automation',
644
+ source_id: id,
645
+ expressions_structure: cond['expressions_structure'] ?? [],
646
+ });
647
+ for (const e of (cond['expressions'] as Row[] | undefined) ?? []) {
648
+ relations.condition_expressions.push({
649
+ condition_id: null,
650
+ _condition_owner: { source_type: 'Automation', source_id: id },
651
+ field_address: e['field_address'],
652
+ field_id: numOrNull(e['field_address']),
653
+ operation: e['operation'],
654
+ value: e['value'],
655
+ structure_id: numOrNull(e['structure_id']),
656
+ });
657
+ }
658
+ }
659
+ }
660
+
661
+ mark('automations', failures ? 'partial' : 'complete');
662
+ mark('field_maps', failures ? 'partial' : 'complete');
663
+ if (relations.conditions.length) mark('conditions', 'partial');
664
+ if (failures) notes.push(`automations: ${failures}/${list.length} could not be loaded in full`);
665
+ }
666
+
667
+ /** One `GET /internal_api/settings/phases/:id` per phase. Recipe step 23. */
668
+ private async readPhaseJumps(
669
+ relations: ReturnType<typeof emptyRelations>,
670
+ mark: (n: EntityName, c: Coverage) => void,
671
+ notes: string[],
672
+ ) {
673
+ const phases = relations.phases;
674
+ if (!phases.length) return;
675
+ let failures = 0;
676
+ const results = await mapPool(phases, LIMITS.readConcurrency, async (p, i) => {
677
+ progress(`phase jumps ${i + 1}/${phases.length}`);
678
+ const s = await this.internal.getPhaseSettings(String(p['id']));
679
+ if (!s) failures++;
680
+ return { phase: p, settings: s };
681
+ });
682
+ progressDone();
683
+
684
+ for (const { phase, settings } of results) {
685
+ if (!settings) continue;
686
+ // `jump_targets` is already the merged set the payload stores.
687
+ for (const t of settings.jump_targets) {
688
+ relations.phase_jumps.push({ source_phase_id: numOrNull(phase['id']), target_phase_id: t });
689
+ }
690
+ }
691
+
692
+ if (failures === phases.length) {
693
+ // Fall back to the public forward-only set, which is better than nothing
694
+ // and honestly marked as incomplete.
695
+ if (this.publicNextPhaseIds.size) {
696
+ for (const [source, targets] of this.publicNextPhaseIds) {
697
+ for (const t of targets) relations.phase_jumps.push({ source_phase_id: source, target_phase_id: t });
698
+ }
699
+ mark('phase_jumps', 'partial');
700
+ notes.push(
701
+ 'phase_jumps: read from the public Phase.next_phase_ids because the internal settings ' +
702
+ 'endpoint was unavailable — forward jumps only, backward jumps unknown',
703
+ );
704
+ return;
705
+ }
706
+ notes.push('phase_jumps: internal settings endpoint unavailable — left unknown');
707
+ return;
708
+ }
709
+
710
+ mark('phase_jumps', failures ? 'partial' : 'complete');
711
+ if (failures) notes.push(`phase_jumps: ${failures}/${phases.length} phases unreadable`);
712
+ }
713
+ }
714
+
715
+ /** camelCase params from the internal API back to the payload's snake_case. */
716
+ const CAMEL_TO_SNAKE: Record<string, string> = {
717
+ toPhaseId: 'to_phase_id',
718
+ fromPhaseId: 'from_phase_id',
719
+ inPhaseId: 'in_phase_id',
720
+ triggerFieldIds: 'trigger_field_ids',
721
+ kindOfSla: 'kind_of_sla',
722
+ triggerAutomationId: 'trigger_automation_id',
723
+ fieldsMapOrder: 'fields_map_order',
724
+ cardId: 'card_id',
725
+ httpMethod: 'http_method',
726
+ authenticationKey: 'authentication_key',
727
+ hasAuthenticationValue: 'has_authentication_value',
728
+ authenticationAddTo: 'authentication_add_to',
729
+ aiParams: 'ai_params',
730
+ slaParams: 'sla_params',
731
+ taskParams: 'task_params',
732
+ fieldIds: 'field_ids',
733
+ skillsIds: 'skills_ids',
734
+ startHour: 'start_hour',
735
+ endHour: 'end_hour',
736
+ fieldId: 'field_id',
737
+ inputMode: 'input_mode',
738
+ dayOfWeek: 'day_of_week',
739
+ dayOfMonth: 'day_of_month',
740
+ };
741
+
742
+ /**
743
+ * Keys the internal API derives and the payload does not store. Carrying them
744
+ * across made every automation report a phantom `action_params` change.
745
+ */
746
+ const DERIVED_PARAM_KEYS = new Set(['hasAuthenticationValue', 'has_authentication_value']);
747
+
748
+ const snakeParams = (v: unknown): unknown => {
749
+ if (Array.isArray(v)) return v.map(snakeParams);
750
+ if (v && typeof v === 'object') {
751
+ const out: Row = {};
752
+ for (const [k, val] of Object.entries(v as Row)) {
753
+ if (k === 'fieldMap') continue; // hoisted into field_maps
754
+ if (DERIVED_PARAM_KEYS.has(k)) continue;
755
+ if (val === null || val === undefined) continue;
756
+ out[CAMEL_TO_SNAKE[k] ?? k] = snakeParams(val);
757
+ }
758
+ return out;
759
+ }
760
+ return v;
761
+ };
762
+
763
+ /** Reported by `pipe doctor` so an FDE knows what a reconstructed pull cannot see. */
764
+ export const RECONSTRUCTION_GAPS = [
765
+ 'public_forms — no public read path',
766
+ 'repo_preferences — no public read path',
767
+ 'visibilities — no public read path (read-only anyway)',
768
+ 'email_templates / email_inboxes — no public read path (read-only anyway)',
769
+ 'labels.uuid — not exposed publicly, so labels are matched by id and name',
770
+ 'response_schemas — kept on the automation row, not as a separate array',
771
+ ] as const;
772
+
773
+ export const warnAboutGaps = () => {
774
+ warn('reconstructed read: ' + RECONSTRUCTION_GAPS.length + ' entity groups are unknown rather than empty');
775
+ };