@pipefy/pipefy-process-coder 0.1.0 → 0.1.1

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