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