@pipefy/pipefy-process-coder 0.1.1 → 0.1.3

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.
@@ -1,6 +1,120 @@
1
1
  import { ENDPOINTS } from "../config.js";
2
2
  import { debug, warn } from "../util/log.js";
3
3
  import { retry } from "../util/pool.js";
4
+ import { AI_BEHAVIOR_PARAMS_SELECTION } from "./agents.js";
5
+ /**
6
+ * The internal-API adapter. PLAN.md §2.1 flags this as an unversioned endpoint
7
+ * with no compatibility contract, so it is isolated here, behind one module,
8
+ * and every entry point degrades to `null`/`unsupported` rather than throwing.
9
+ *
10
+ * Two things live only here — both taken from the Change Migrator recipes,
11
+ * which have been running them in production:
12
+ *
13
+ * 1. Automations. Public `getAutomations` returns a summary; the recipes use
14
+ * `loadAutomationToEdit` to get `action_params.field_map`, `searchFor`,
15
+ * `responseSchema`, `schedulerCron` and `aiParams` — everything a
16
+ * round trip needs and the summary omits.
17
+ * 2. Phase jumps. `GET/PUT /internal_api/settings/phases/:id`, set-replacing.
18
+ *
19
+ * Both accept the same personal API token as the public endpoint, so there is
20
+ * no session-cookie or CSRF subsystem here.
21
+ */
22
+ /** Sent by `listAutomations`; module-level so `checkQueries` validates the same text. */
23
+ const LIST_AUTOMATIONS_QUERY = `
24
+ query getAutomations($orgId: ID!, $repoId: ID) {
25
+ automations(organizationId: $orgId, repoId: $repoId) {
26
+ id
27
+ name
28
+ action_id
29
+ event_id
30
+ created_at
31
+ active
32
+ action_repo: action_repo_v2 {
33
+ __typename
34
+ ... on Pipe { id }
35
+ ... on Table { id }
36
+ }
37
+ event_repo { __typename id }
38
+ }
39
+ }`;
40
+ /** Sent by `loadAutomation`; module-level so `checkQueries` validates the same text. */
41
+ const LOAD_AUTOMATION_QUERY = `
42
+ query loadAutomationToEdit($id: ID!) {
43
+ automation(id: $id) {
44
+ name
45
+ event_id
46
+ active
47
+ event_repo { id }
48
+ event_params {
49
+ toPhaseId: to_phase_id
50
+ fromPhaseId
51
+ inPhaseId
52
+ triggerFieldIds
53
+ kindOfSla
54
+ triggerAutomationId
55
+ }
56
+ action_id
57
+ action_repo: action_repo_v2 {
58
+ ... on Pipe { id uuid }
59
+ ... on Table { id uuid }
60
+ }
61
+ scheduler_frequency
62
+ schedulerCron { minute hour dayOfWeek dayOfMonth month }
63
+ searchFor { id field operation value }
64
+ responseSchema
65
+ action_params {
66
+ toPhaseId: to_phase_id
67
+ fieldsMapOrder: fields_map_order
68
+ email_template_id
69
+ fieldMap: field_map { fieldId value inputMode }
70
+ cardId: card_id
71
+ headers
72
+ body
73
+ url
74
+ httpMethod
75
+ authenticationKey
76
+ hasAuthenticationValue
77
+ authenticationAddTo
78
+ authenticationType
79
+ strategy
80
+ # taskParams is not in the recipe's query, and the omission is not
81
+ # cosmetic: every send_a_task automation came back with an empty
82
+ # action_params, so a reconstructed read lost its recipients and
83
+ # title, and a recreate would have rebuilt it blank. Introspected
84
+ # from AutomationActionParams on the internal endpoint.
85
+ taskParams { recipients title }
86
+ aiParams { fieldIds value skillsIds }
87
+ aiBehaviorParams ${AI_BEHAVIOR_PARAMS_SELECTION}
88
+ slaParams {
89
+ monday { startHour endHour enabled }
90
+ tuesday { startHour endHour enabled }
91
+ wednesday { startHour endHour enabled }
92
+ thursday { startHour endHour enabled }
93
+ friday { startHour endHour enabled }
94
+ saturday { startHour endHour enabled }
95
+ sunday { startHour endHour enabled }
96
+ timezone
97
+ holidays { date recurrence description }
98
+ }
99
+ }
100
+ condition {
101
+ expressions_structure
102
+ expressions { id field_address operation value structure_id }
103
+ }
104
+ }
105
+ }`;
106
+ /**
107
+ * Every read query document this adapter sends over GraphQL, with variables
108
+ * that match nothing. GraphQL validates the document before resolving any of
109
+ * it, so a query that has drifted from the schema is refused the same way
110
+ * whichever id it names — and a valid one answers `data` with a not-found
111
+ * error. No write is in this list, and none may be added: probing is not free
112
+ * on this platform (AGENTS.md §4).
113
+ */
114
+ const READ_QUERIES = [
115
+ { label: 'listAutomations', query: LIST_AUTOMATIONS_QUERY, variables: { orgId: '0', repoId: '0' } },
116
+ { label: 'loadAutomation', query: LOAD_AUTOMATION_QUERY, variables: { id: '0' } },
117
+ ];
4
118
  export class InternalApi {
5
119
  tokenProvider;
6
120
  status = 'unknown';
@@ -14,15 +128,83 @@ export class InternalApi {
14
128
  return this.tokenProvider();
15
129
  }
16
130
  markUnavailable(reason) {
17
- this.status = 'unavailable';
131
+ if (this.status !== 'incompatible')
132
+ this.status = 'unavailable';
18
133
  if (!this.warned) {
19
134
  this.warned = true;
20
- warn(`internal API unavailable (${reason}). Automation details and phase jumps ` +
135
+ warn(
136
+ // "Unavailable" read as an outage for answers like "Permission denied"
137
+ // or "Couldn't find Repo", which are the endpoint answering about this
138
+ // pipe and this credential. The reason says which.
139
+ `internal API call failed (${reason}). Automation details and phase jumps ` +
21
140
  `will be reported as unknown rather than empty — see PLAN.md §2.1.`);
22
141
  }
23
142
  }
24
- /** GraphQL over the internal endpoint. Returns null when the endpoint fails. */
25
- async gql(query, variables, label) {
143
+ /** One answered call does not clear a refused query: that stays visible. */
144
+ markOk() {
145
+ if (this.status !== 'incompatible')
146
+ this.status = 'ok';
147
+ }
148
+ markIncompatible(label, reason) {
149
+ this.status = 'incompatible';
150
+ if (!this.warned) {
151
+ this.warned = true;
152
+ warn(`internal API rejected this tool's ${label} query as invalid (${reason}). ` +
153
+ `That is a bug in pipe, not an outage — the endpoint changed under it. Automation ` +
154
+ `details will be reported as unknown rather than empty; \`pipe doctor\` checks every internal query.`);
155
+ }
156
+ }
157
+ /**
158
+ * Validate every read query against the live schema without reading anything
159
+ * real. Null when the endpoint itself could not be reached, which says
160
+ * nothing about the queries.
161
+ */
162
+ async checkQueries() {
163
+ const results = [];
164
+ for (const { label, query, variables } of READ_QUERIES) {
165
+ let body;
166
+ try {
167
+ this.requestCount++;
168
+ const res = await fetch(ENDPOINTS.internal, {
169
+ method: 'POST',
170
+ headers: {
171
+ authorization: `Bearer ${await this.bearer()}`,
172
+ 'content-type': 'application/json',
173
+ accept: 'application/json',
174
+ },
175
+ body: JSON.stringify({ query, variables }),
176
+ });
177
+ if (!res.ok) {
178
+ debug(`internal query check ${label}: HTTP ${res.status}`);
179
+ return null;
180
+ }
181
+ body = JSON.parse(await res.text());
182
+ }
183
+ catch (e) {
184
+ debug(`internal query check ${label}: ${e.message}`);
185
+ return null;
186
+ }
187
+ if ('data' in body)
188
+ results.push({ label, ok: true });
189
+ else
190
+ results.push({ label, ok: false, message: (body.errors ?? []).map((e) => e.message).join('; ') || 'no data' });
191
+ }
192
+ this.status = results.every((r) => r.ok) ? 'ok' : 'incompatible';
193
+ return results;
194
+ }
195
+ /** The message of the last failed `gql` call, for a caller that reports it itself. */
196
+ lastError = null;
197
+ /**
198
+ * GraphQL over the internal endpoint. Returns null when the endpoint fails.
199
+ *
200
+ * `soft` is for a call whose failure is an answer about this pipe rather than
201
+ * about the endpoint — the iPaaS token, refused with "iPaaS is disabled for
202
+ * your organization". It used to mark the adapter unavailable and print that
203
+ * automation details and phase jumps would be unknown, on a pull where both
204
+ * had just been read. The caller reports it, from `lastError`.
205
+ */
206
+ async gql(query, variables, label, opts = {}) {
207
+ this.lastError = null;
26
208
  try {
27
209
  const data = await retry(async () => {
28
210
  this.requestCount++;
@@ -45,41 +227,33 @@ export class InternalApi {
45
227
  if (body.errors?.length) {
46
228
  const err = new Error(body.errors.map((e) => e.message).join('; '));
47
229
  err.retryable = false;
230
+ // No `data` key at all is a document the schema refused, not a
231
+ // resolver failing on this one id — see InternalStatus.
232
+ err.invalidQuery = !('data' in body);
48
233
  throw err;
49
234
  }
50
235
  return body.data ?? null;
51
236
  }, { label });
52
237
  if (data)
53
- this.status = 'ok';
238
+ this.markOk();
54
239
  return data;
55
240
  }
56
241
  catch (e) {
57
242
  debug(`internal gql ${label} failed: ${e.message}`);
58
- this.markUnavailable(`${label}: ${e.message}`);
243
+ this.lastError = e.message;
244
+ if (opts.soft && !e.invalidQuery)
245
+ return null;
246
+ if (e.invalidQuery)
247
+ this.markIncompatible(label, e.message);
248
+ else
249
+ this.markUnavailable(`${label}: ${e.message}`);
59
250
  return null;
60
251
  }
61
252
  }
62
253
  // ── Automations ────────────────────────────────────────────────────────────
63
254
  /** Recipe `Pipe Data Treatment` step 156. */
64
255
  async listAutomations(orgId, repoId) {
65
- const q = `
66
- query getAutomations($orgId: ID!, $repoId: ID) {
67
- automations(organizationId: $orgId, repoId: $repoId) {
68
- id
69
- name
70
- action_id
71
- event_id
72
- created_at
73
- active
74
- action_repo: action_repo_v2 {
75
- __typename
76
- ... on Pipe { id }
77
- ... on Table { id }
78
- }
79
- event_repo { __typename id }
80
- }
81
- }`;
82
- const data = await this.gql(q, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
256
+ const data = await this.gql(LIST_AUTOMATIONS_QUERY, { orgId: String(orgId), repoId: String(repoId) }, 'listAutomations');
83
257
  return data?.automations ?? null;
84
258
  }
85
259
  /**
@@ -89,72 +263,7 @@ export class InternalApi {
89
263
  * cannot avoid the internal API for automations.
90
264
  */
91
265
  async loadAutomation(id) {
92
- const q = `
93
- query loadAutomationToEdit($id: ID!) {
94
- automation(id: $id) {
95
- name
96
- event_id
97
- active
98
- event_repo { id }
99
- event_params {
100
- toPhaseId: to_phase_id
101
- fromPhaseId
102
- inPhaseId
103
- triggerFieldIds
104
- kindOfSla
105
- triggerAutomationId
106
- }
107
- action_id
108
- action_repo: action_repo_v2 {
109
- ... on Pipe { id uuid }
110
- ... on Table { id uuid }
111
- }
112
- scheduler_frequency
113
- schedulerCron { minute hour dayOfWeek dayOfMonth month }
114
- searchFor { id field operation value }
115
- responseSchema
116
- action_params {
117
- toPhaseId: to_phase_id
118
- fieldsMapOrder: fields_map_order
119
- email_template_id
120
- fieldMap: field_map { fieldId value inputMode }
121
- cardId: card_id
122
- headers
123
- body
124
- url
125
- httpMethod
126
- authenticationKey
127
- hasAuthenticationValue
128
- authenticationAddTo
129
- authenticationType
130
- strategy
131
- # taskParams is not in the recipe's query, and the omission is not
132
- # cosmetic: every send_a_task automation came back with an empty
133
- # action_params, so a reconstructed read lost its recipients and
134
- # title, and a recreate would have rebuilt it blank. Introspected
135
- # from AutomationActionParams on the internal endpoint.
136
- taskParams { recipients title }
137
- aiParams { fieldIds value skillsIds }
138
- aiBehaviorParams
139
- slaParams {
140
- monday { startHour endHour enabled }
141
- tuesday { startHour endHour enabled }
142
- wednesday { startHour endHour enabled }
143
- thursday { startHour endHour enabled }
144
- friday { startHour endHour enabled }
145
- saturday { startHour endHour enabled }
146
- sunday { startHour endHour enabled }
147
- timezone
148
- holidays { date recurrence description }
149
- }
150
- }
151
- condition {
152
- expressions_structure
153
- expressions { id field_address operation value structure_id }
154
- }
155
- }
156
- }`;
157
- const data = await this.gql(q, { id: String(id) }, `loadAutomation ${id}`);
266
+ const data = await this.gql(LOAD_AUTOMATION_QUERY, { id: String(id) }, `loadAutomation ${id}`);
158
267
  return data?.automation ?? null;
159
268
  }
160
269
  /** Recipe `Apply Changes` step 121. `input` is the AutomationInput variable set. */
@@ -236,7 +345,7 @@ export class InternalApi {
236
345
  const d = res.data ?? {};
237
346
  const attrs = d.attributes ?? {};
238
347
  const targets = attrs.jump_targets ?? d.jump_targets ?? [...(d.next_phase_ids ?? []), ...(d.back_phase_ids ?? [])];
239
- this.status = 'ok';
348
+ this.markOk();
240
349
  return {
241
350
  jump_targets: [...new Set(targets.map(Number).filter((n) => Number.isFinite(n)))],
242
351
  phases: (attrs.phases ?? []).map(([id, name, done]) => ({ id: Number(id), name: String(name), done: Boolean(done) })),
@@ -302,7 +411,7 @@ export class InternalApi {
302
411
  const want = [...new Set(targetPhaseIds.map(Number))].sort((a, b) => a - b);
303
412
  const got = [...new Set(echoed)].sort((a, b) => a - b);
304
413
  const ok = want.length === got.length && want.every((v, i) => v === got[i]);
305
- this.status = 'ok';
414
+ this.markOk();
306
415
  return ok
307
416
  ? { ok, echoed }
308
417
  : { ok, echoed, error: `echo mismatch: wanted [${want.join(',')}], endpoint reports [${got.join(',')}]` };
@@ -86,11 +86,12 @@ export class IpaasClient {
86
86
  return hit.session;
87
87
  const minted = await this.internal.gql(`query GetAdvancedAutomationsToken($repoId: ID!) {
88
88
  advancedAutomationsToken(repoId: $repoId) { token }
89
- }`, { repoId: String(pipeId) }, 'GetAdvancedAutomationsToken');
89
+ }`, { repoId: String(pipeId) }, 'GetAdvancedAutomationsToken', { soft: true });
90
90
  const token = minted?.advancedAutomationsToken?.token;
91
91
  if (!token) {
92
- throw new IpaasUnavailable(pipeId, `no advanced-automations token for pipe ${pipeId} — iPaaS may be off for this organization, ` +
93
- 'or this credential may not be allowed to create automations on the pipe');
92
+ const said = this.internal.lastError;
93
+ throw new IpaasUnavailable(pipeId, `no advanced-automations token for pipe ${pipeId} — ` +
94
+ (said ?? 'iPaaS may be off for this organization, or this credential may not be allowed to create automations on the pipe'));
94
95
  }
95
96
  const res = await fetch(`${this.base}/managed-authn/external-token`, {
96
97
  method: 'POST',
@@ -236,7 +236,8 @@ export class Reconstructor {
236
236
  p['index'] = i;
237
237
  });
238
238
  relations.phases = phaseRows;
239
- mark('phases', phase.missing ? 'partial' : 'complete');
239
+ // Every phase comes back; what can be missing is columns, recorded below.
240
+ mark('phases', 'complete');
240
241
  /**
241
242
  * `Phase.next_phase_ids` is public, but it is **not** the payload's
242
243
  * `phase_jumps`. Checked against a snapshot of the same pipe:
@@ -291,7 +292,7 @@ export class Reconstructor {
291
292
  for (const p of gqlPhases)
292
293
  pushFields(p['fields'] ?? [], numOrNull(p['id']));
293
294
  relations.fields = fieldRows;
294
- mark('fields', field.missing ? 'partial' : 'complete');
295
+ mark('fields', 'complete');
295
296
  if (field.missing)
296
297
  notes.push(`field columns not in this schema: ${field.skipped.join(', ')}`);
297
298
  // ── labels ──────────────────────────────────────────────────────────────
@@ -303,9 +304,9 @@ export class Reconstructor {
303
304
  color: l['color'],
304
305
  repo_id: root['id'],
305
306
  }));
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');
307
+ // Every label comes back. No `uuid` over the public API — and uuid is how
308
+ // a snapshot identifies labels — but that is a column, recorded below.
309
+ mark('labels', 'complete');
309
310
  }
310
311
  else {
311
312
  notes.push('labels: not readable over this endpoint');
@@ -329,27 +330,115 @@ export class Reconstructor {
329
330
  notes.push('webhooks: not readable over this endpoint');
330
331
  }
331
332
  // ── 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
- }
333
+ /**
334
+ * `Pipe.pipe_relations` does not exist — this used to ask for it, the prune
335
+ * dropped it, and every reconstructed pull had pipe_relations `unknown`.
336
+ * The public names are `parentsRelations` and `childrenRelations`, and their
337
+ * `parent` / `child` are a union (Pipe | Table), so `{ id }` is refused
338
+ * until it is spread per type. Both directions are read and merged by id:
339
+ * which of them a snapshot carries has not been measured, and a superset
340
+ * can only add a row a snapshot lacks, never hide one this pipe has.
341
+ */
342
+ const REL = `{ id name
343
+ parent { ... on Pipe { id } ... on Table { id } }
344
+ child { ... on Pipe { id } ... on Table { id } }
345
+ canCreateNewItems canConnectExistingItems canConnectMultipleItems
346
+ childMustExistToMoveParent childMustExistToFinishParent
347
+ allChildrenMustBeDoneToMoveParent allChildrenMustBeDoneToFinishParent }`;
348
+ const pr = await this.client.tryRaw(`query($id: ID!) { pipe(id: $id) { parentsRelations ${REL} childrenRelations ${REL} } }`, { id: String(repoId) }, 'pipe_relations');
349
+ if (pr?.pipe && Array.isArray(pr.pipe.parentsRelations) && Array.isArray(pr.pipe.childrenRelations)) {
350
+ const byId = new Map();
351
+ for (const r of [...pr.pipe.parentsRelations, ...pr.pipe.childrenRelations])
352
+ byId.set(String(r['id']), r);
353
+ relations.pipe_relations = [...byId.values()].map((r) => ({
354
+ id: numOrNull(r['id']),
355
+ name: r['name'],
356
+ parent_id: numOrNull(firstId(r['parent'])),
357
+ child_id: numOrNull(firstId(r['child'])),
358
+ can_create_new_items: r['canCreateNewItems'],
359
+ can_connect_existing_items: r['canConnectExistingItems'],
360
+ can_connect_multiple_items: r['canConnectMultipleItems'],
361
+ child_must_exist_to_move_parent: r['childMustExistToMoveParent'],
362
+ child_must_exist_to_finish_parent: r['childMustExistToFinishParent'],
363
+ all_children_must_be_done_to_move_parent: r['allChildrenMustBeDoneToMoveParent'],
364
+ all_children_must_be_done_to_finish_parent: r['allChildrenMustBeDoneToFinishParent'],
365
+ }));
366
+ mark('pipe_relations', 'complete');
367
+ }
368
+ else {
369
+ notes.push('pipe_relations: parentsRelations / childrenRelations did not answer');
370
+ }
371
+ // ── phase team members ──────────────────────────────────────────────────
372
+ // `Phase.teamMembers` is public; the payload holds one row per member.
373
+ const tm = await this.client.tryRaw(`query($id: ID!) { pipe(id: $id) { phases { id teamMembers { id } } } }`, { id: String(repoId) }, 'phase team members');
374
+ if (tm?.pipe?.phases?.every((ph) => Array.isArray(ph.teamMembers))) {
375
+ relations.phase_team_memberships = tm.pipe.phases.flatMap((ph) => (ph.teamMembers ?? []).map((u) => ({ phase_id: numOrNull(ph.id), user_id: numOrNull(u['id']) })));
376
+ mark('phase_team_memberships', 'complete');
377
+ }
378
+ else {
379
+ notes.push('phase_team_memberships: Phase.teamMembers did not answer');
380
+ }
381
+ // ── repo preferences and the public form ────────────────────────────────
382
+ /**
383
+ * One row each, both public. Fewer columns than a snapshot's (no id, no
384
+ * custom sorting, no guests settings) — columns are partial, as for every
385
+ * reconstructed group — but every column the compiler can write is here.
386
+ * `submitterEmailCollectionMethod` is the payload's `submitter_email_required`:
387
+ * both read "required" on the same pipe.
388
+ */
389
+ const prefs = await this.client.tryRaw(`query($id: ID!) { pipe(id: $id) { preferences {
390
+ aiAgentsEnabled aiCopilotEnabled findable hiddenStartFormAttributes
391
+ hiddenTopButtons inboxEmailEnabled mainTabViews startFormTitle } } }`, { id: String(repoId) }, 'repo_preferences');
392
+ if (prefs?.pipe) {
393
+ const r = prefs.pipe.preferences;
394
+ relations.repo_preferences = r
395
+ ? [
396
+ {
397
+ repo_id: root['id'],
398
+ ai_agents_enabled: r['aiAgentsEnabled'],
399
+ ai_copilot_enabled: r['aiCopilotEnabled'],
400
+ findable: r['findable'],
401
+ hidden_start_form_attributes: r['hiddenStartFormAttributes'] ?? [],
402
+ hidden_top_buttons: r['hiddenTopButtons'] ?? [],
403
+ inbox_email_enabled: r['inboxEmailEnabled'],
404
+ main_tab_views: r['mainTabViews'] ?? [],
405
+ start_form_title: r['startFormTitle'] ?? null,
406
+ },
407
+ ]
408
+ : [];
409
+ mark('repo_preferences', 'complete');
410
+ }
411
+ const pf = await this.client.tryRaw(`query($id: ID!) { pipe(id: $id) { publicForm {
412
+ id active title description submitButtonText afterSubmitMessage brandColor
413
+ backgroundColor backgroundImage logo displayPipefyLogo showSubmitAnotherResponseButton
414
+ reuseLastSubmissionResponse submitterEmailCollectionEnabled
415
+ submitterEmailCollectionMethod submitterEmailField { id } } } }`, { id: String(repoId) }, 'public_forms');
416
+ if (pf?.pipe) {
417
+ const f = pf.pipe.publicForm;
418
+ relations.public_forms = f
419
+ ? [
420
+ {
421
+ id: numOrNull(f['id']),
422
+ repo_id: root['id'],
423
+ active: f['active'],
424
+ title: f['title'] ?? null,
425
+ description: f['description'] ?? null,
426
+ submit_button_text: f['submitButtonText'] ?? null,
427
+ after_submit_message: f['afterSubmitMessage'] ?? null,
428
+ brand_color: f['brandColor'] ?? null,
429
+ background_color: f['backgroundColor'] ?? null,
430
+ background_image: f['backgroundImage'] ?? null,
431
+ logo: f['logo'] ?? null,
432
+ display_pipefy_logo: f['displayPipefyLogo'],
433
+ show_submit_another_response_button: f['showSubmitAnotherResponseButton'],
434
+ reuse_last_submission_response: f['reuseLastSubmissionResponse'],
435
+ submitter_email_collection_enabled: f['submitterEmailCollectionEnabled'],
436
+ submitter_email_required: f['submitterEmailCollectionMethod'] ?? null,
437
+ submitter_email_field_id: numOrNull(firstId(f['submitterEmailField'])),
438
+ },
439
+ ]
440
+ : [];
441
+ mark('public_forms', 'complete');
353
442
  }
354
443
  // ── field conditions (+ actions, condition, expressions) ────────────────
355
444
  await this.readFieldConditions(repoId, root, relations, mark, notes);
@@ -367,19 +456,27 @@ export class Reconstructor {
367
456
  }
368
457
  }
369
458
  /**
370
- * No reconstructed group is ever 'complete'.
459
+ * No reconstructed group has every *column*; most have every *row*.
371
460
  *
372
- * Row coverage and *column* coverage are different things: the public API
461
+ * Row coverage and column coverage are different things: the public API
373
462
  * 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.
463
+ * or `repo_id`. Marking such a group's columns complete made a
464
+ * snapshot-vs-reconstruction diff report ten phantom changes per phase, each
465
+ * one a proposal to null a column that had simply not been read — so every
466
+ * group that was read has its columns marked partial.
467
+ *
468
+ * Rows are another matter, and they used to be marked partial too. That
469
+ * made the diff refuse every create and delete on a reconstructed pull,
470
+ * which was every pull while the snapshot download answered 500: a new
471
+ * phase could not be created because the read had not seen `color_uuid`.
472
+ * `coverage` now says what was measured about rows — complete where the
473
+ * API returns the whole list, partial where it is known not to
474
+ * (phase_jumps read forward-only, pipe_relations never compared against a
475
+ * snapshot, automations that would not load).
378
476
  */
379
- for (const name of ENTITY_NAMES) {
380
- if (coverage[name] === 'complete')
381
- coverage[name] = 'partial';
382
- }
477
+ const columns = {};
478
+ for (const name of ENTITY_NAMES)
479
+ columns[name] = coverage[name] === 'unknown' ? 'unknown' : 'partial';
383
480
  const payload = { ...root, id: Number(root['id']), relations };
384
481
  return {
385
482
  source: 'reconstructed',
@@ -387,6 +484,7 @@ export class Reconstructor {
387
484
  readAt: new Date().toISOString(),
388
485
  repoKind: 'pipe',
389
486
  coverage,
487
+ columns,
390
488
  repo_columns: pipe.missing ? 'partial' : 'complete',
391
489
  notes,
392
490
  payload,
@@ -472,7 +570,16 @@ export class Reconstructor {
472
570
  * shortcut — this is N+1 by necessity, bounded by readConcurrency.
473
571
  */
474
572
  async readAutomations(repoId, root, relations, mark, notes, organizationId) {
475
- const orgId = organizationId ?? (root['organization_id'] ? String(root['organization_id']) : null);
573
+ /**
574
+ * The repo's own organization first. The one passed in is the caller's
575
+ * default — `--org`, or whatever `pipe login --token` saved — and a token
576
+ * that belongs to several organizations pulls pipes from all of them. Sent
577
+ * a pipe from another organization, the internal query answers "Couldn't
578
+ * find Repo with 'id'=…" about a pipe that plainly exists, and every
579
+ * automation is left unknown. Seen in a field run, reproduced here with
580
+ * one pipe under its own organization (32 automations) and under another.
581
+ */
582
+ const orgId = (root['organization_id'] ? String(root['organization_id']) : null) ?? organizationId;
476
583
  if (!orgId) {
477
584
  notes.push('automations: no organization id available for the internal automations query');
478
585
  return;
@@ -626,6 +733,8 @@ const CAMEL_TO_SNAKE = {
626
733
  hasAuthenticationValue: 'has_authentication_value',
627
734
  authenticationAddTo: 'authentication_add_to',
628
735
  aiParams: 'ai_params',
736
+ // Its own subfields arrive already aliased to snake_case (AI_BEHAVIOR_PARAMS_SELECTION).
737
+ aiBehaviorParams: 'ai_behavior_params',
629
738
  slaParams: 'sla_params',
630
739
  taskParams: 'task_params',
631
740
  fieldIds: 'field_ids',
@@ -662,8 +771,7 @@ const snakeParams = (v) => {
662
771
  };
663
772
  /** Reported by `pipe doctor` so an FDE knows what a reconstructed pull cannot see. */
664
773
  export const RECONSTRUCTION_GAPS = [
665
- 'public_forms — no public read path',
666
- 'repo_preferences — no public read path',
774
+ 'public_forms / repo_preferences — read, with fewer columns than a snapshot (no ids for preferences, no custom sorting)',
667
775
  'visibilities — no public read path (read-only anyway)',
668
776
  'email_templates / email_inboxes — no public read path (read-only anyway)',
669
777
  'labels.uuid — not exposed publicly, so labels are matched by id and name',