@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.
- package/AGENTS.md +33 -2
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +6 -2
- package/dist/apply/adopt.js +52 -0
- package/dist/cli.js +14 -5
- package/dist/codec/pack.js +2 -0
- package/dist/codec/unpack.js +1 -0
- package/dist/commands/apply.js +25 -6
- package/dist/commands/context.js +40 -2
- package/dist/commands/diff.js +22 -20
- package/dist/commands/misc.js +55 -34
- package/dist/commands/plan.js +3 -1
- package/dist/commands/pull.js +54 -6
- package/dist/commands/verify.js +11 -32
- package/dist/diff/diff.js +28 -4
- package/dist/pipefy/agents.js +36 -27
- package/dist/pipefy/internal.js +201 -92
- package/dist/pipefy/ipaas.js +4 -3
- package/dist/pipefy/reconstruct.js +147 -39
- package/dist/pipefy/toolkit.js +27 -11
- package/dist/pipefy/toolkit_bearer.py +60 -14
- package/dist/report/run.js +2 -1
- package/dist/util/log.js +14 -4
- package/dist/util/tui.js +43 -2
- package/dist/version.js +18 -0
- package/dist/workspace/docs.js +1 -1
- package/dist/workspace/read.js +1 -0
- package/package.json +4 -3
package/dist/pipefy/internal.js
CHANGED
|
@@ -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
|
|
131
|
+
if (this.status !== 'incompatible')
|
|
132
|
+
this.status = 'unavailable';
|
|
18
133
|
if (!this.warned) {
|
|
19
134
|
this.warned = true;
|
|
20
|
-
warn(
|
|
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
|
-
/**
|
|
25
|
-
|
|
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.
|
|
238
|
+
this.markOk();
|
|
54
239
|
return data;
|
|
55
240
|
}
|
|
56
241
|
catch (e) {
|
|
57
242
|
debug(`internal gql ${label} failed: ${e.message}`);
|
|
58
|
-
this.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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(',')}]` };
|
package/dist/pipefy/ipaas.js
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
307
|
-
// a snapshot —
|
|
308
|
-
mark('labels', '
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
|
459
|
+
* No reconstructed group has every *column*; most have every *row*.
|
|
371
460
|
*
|
|
372
|
-
* Row coverage and
|
|
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
|
|
375
|
-
* diff report ten phantom changes per phase, each
|
|
376
|
-
* column that had simply not been read
|
|
377
|
-
*
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
|
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',
|