@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,955 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { debug, warn } from '../util/log.ts';
|
|
3
|
+
import type { PipefyClient } from '../pipefy/client.ts';
|
|
4
|
+
import type { Row } from '../model/payload.ts';
|
|
5
|
+
import type { StepRequest } from './types.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mutation builders.
|
|
9
|
+
*
|
|
10
|
+
* Every shape here is the one the Change Migrator recipes actually send —
|
|
11
|
+
* including the details that are easy to get wrong and that the recipes prove:
|
|
12
|
+
*
|
|
13
|
+
* - `deletePhaseField` needs `pipeUuid` alongside the field id (step 43/63)
|
|
14
|
+
* - `createPhaseField` takes `label`, `phase_id`, `type`, `options`,
|
|
15
|
+
* `description` (step 34)
|
|
16
|
+
* - `createFieldCondition` takes the read shape after stripping labels and
|
|
17
|
+
* renaming `phase → phaseId`, `phaseField → phaseFieldId` (steps 85/86),
|
|
18
|
+
* while `updateFieldCondition` wants `phase_id` (steps 109/110)
|
|
19
|
+
*
|
|
20
|
+
* Argument sets are then filtered against the mutation's real input type, so an
|
|
21
|
+
* argument this Pipefy version does not accept is dropped with a debug line
|
|
22
|
+
* instead of failing the whole call.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const dropUndefined = (o: Row): Row => {
|
|
26
|
+
const out: Row = {};
|
|
27
|
+
for (const [k, v] of Object.entries(o)) if (v !== undefined) out[k] = v;
|
|
28
|
+
return out;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build `mutation($input: <T>!) { <name>(input: $input) { <selection> } }` with
|
|
33
|
+
* the input filtered to the fields the schema accepts.
|
|
34
|
+
*/
|
|
35
|
+
export const buildGraphql = async (
|
|
36
|
+
client: PipefyClient | null,
|
|
37
|
+
mutation: string,
|
|
38
|
+
input: Row,
|
|
39
|
+
selection: string,
|
|
40
|
+
): Promise<StepRequest> => {
|
|
41
|
+
/**
|
|
42
|
+
* Offline (no token): fall back to the conventional input type name and keep
|
|
43
|
+
* every argument. Classification and ordering are what `pipe plan` is for, and
|
|
44
|
+
* both are pure functions of the ChangeSet — so they must not need a network.
|
|
45
|
+
* The steps are marked unverified, and `pipe apply` re-compiles with a client.
|
|
46
|
+
*/
|
|
47
|
+
const typeName = (client ? await client.mutationInputType(mutation) : null) ?? `${mutation[0]?.toUpperCase()}${mutation.slice(1)}Input`;
|
|
48
|
+
const accepted = client ? await client.inputFields(typeName) : null;
|
|
49
|
+
const required = client ? await client.requiredInputFields(typeName) : new Set<string>();
|
|
50
|
+
|
|
51
|
+
const filtered: Row = {};
|
|
52
|
+
const dropped: string[] = [];
|
|
53
|
+
for (const [k, v] of Object.entries(dropUndefined(input))) {
|
|
54
|
+
if (accepted && !accepted.has(k)) {
|
|
55
|
+
dropped.push(k);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
filtered[k] = v;
|
|
59
|
+
}
|
|
60
|
+
if (dropped.length) debug(`${mutation}: dropped args not in ${typeName} — ${dropped.join(', ')}`);
|
|
61
|
+
|
|
62
|
+
const missingRequired = [...required].filter((k) => filtered[k] === undefined || filtered[k] === null);
|
|
63
|
+
if (missingRequired.length) {
|
|
64
|
+
debug(`${mutation}: missing required ${typeName} fields — ${missingRequired.join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
mutation,
|
|
69
|
+
...(missingRequired.length ? { missingRequired } : {}),
|
|
70
|
+
query: `mutation($input: ${typeName}!) {\n ${mutation}(input: $input) {\n clientMutationId\n ${selection}\n }\n}`,
|
|
71
|
+
variables: { input: filtered },
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ── phases ───────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
export const createPhase = (client: PipefyClient | null, pipeId: number, phase: Row) =>
|
|
78
|
+
buildGraphql(
|
|
79
|
+
client,
|
|
80
|
+
'createPhase',
|
|
81
|
+
{
|
|
82
|
+
pipe_id: String(pipeId),
|
|
83
|
+
name: phase['name'],
|
|
84
|
+
done: phase['done'],
|
|
85
|
+
description: phase['description'],
|
|
86
|
+
lateness_time: phase['lateness_time'],
|
|
87
|
+
can_receive_card_directly_from_draft: phase['can_receive_card_directly_from_draft'],
|
|
88
|
+
only_admin_can_move_to_previous: phase['only_admin_can_move_to_previous'],
|
|
89
|
+
// createPhase takes index: Float — updatePhase does not (PLAN.md §2).
|
|
90
|
+
index: phase['index'],
|
|
91
|
+
uuid: phase['uuid'],
|
|
92
|
+
},
|
|
93
|
+
'phase { id name index }',
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
export const updatePhase = (client: PipefyClient | null, phase: Row) =>
|
|
97
|
+
buildGraphql(
|
|
98
|
+
client,
|
|
99
|
+
'updatePhase',
|
|
100
|
+
{
|
|
101
|
+
id: String(phase['id']),
|
|
102
|
+
name: phase['name'],
|
|
103
|
+
done: phase['done'],
|
|
104
|
+
description: phase['description'],
|
|
105
|
+
lateness_time: phase['lateness_time'],
|
|
106
|
+
can_receive_card_directly_from_draft: phase['can_receive_card_directly_from_draft'],
|
|
107
|
+
only_admin_can_move_to_previous: phase['only_admin_can_move_to_previous'],
|
|
108
|
+
},
|
|
109
|
+
'phase { id name }',
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
export const deletePhase = (client: PipefyClient | null, phaseId: string | number) =>
|
|
113
|
+
buildGraphql(client, 'deletePhase', { id: String(phaseId) }, 'success');
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* `phaseSettings` takes `SettingsInput`, keyed on `id` — not `phaseId`, which is
|
|
117
|
+
* what an earlier version sent. It is also a superset of `updatePhase`: name,
|
|
118
|
+
* description, done, lateness, colour and the team all live here.
|
|
119
|
+
*/
|
|
120
|
+
export const phaseSettings = (client: PipefyClient | null, phaseId: string | number, settings: Row) =>
|
|
121
|
+
buildGraphql(
|
|
122
|
+
client,
|
|
123
|
+
'phaseSettings',
|
|
124
|
+
{
|
|
125
|
+
id: String(phaseId),
|
|
126
|
+
identifyTask: settings['identify_task'],
|
|
127
|
+
latenessTime: settings['lateness_time'],
|
|
128
|
+
teamMemberIds: Array.isArray(settings['team_member_ids'])
|
|
129
|
+
? (settings['team_member_ids'] as unknown[]).map(String)
|
|
130
|
+
: undefined,
|
|
131
|
+
},
|
|
132
|
+
'clientMutationId',
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// ── fields ───────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `label` is NON_NULL on update, so the compiler always sends the full object
|
|
139
|
+
* rather than a sparse patch (PLAN.md §2).
|
|
140
|
+
*/
|
|
141
|
+
const fieldArgs = (field: Row): Row => ({
|
|
142
|
+
label: field['label'],
|
|
143
|
+
options: field['options'],
|
|
144
|
+
description: field['description'],
|
|
145
|
+
help: field['help'],
|
|
146
|
+
required: field['required'],
|
|
147
|
+
editable: field['editable'],
|
|
148
|
+
minimal_view: field['minimal_view'],
|
|
149
|
+
custom_validation: field['custom_validation'],
|
|
150
|
+
unique: field['unique'],
|
|
151
|
+
index: field['index'],
|
|
152
|
+
sync_with_card: field['card_synced'],
|
|
153
|
+
can_create_database_record: field['can_create_connected_cards'],
|
|
154
|
+
can_have_multiple_database_records: field['can_connect_multiple_cards'],
|
|
155
|
+
child_must_exist_to_finish_parent: field['child_must_exist_to_finish_parent'],
|
|
156
|
+
all_children_must_be_done_to_move_parent: field['all_children_must_be_done_to_move_parent'],
|
|
157
|
+
all_children_must_be_done_to_finish_parent: field['all_children_must_be_done_to_finish_parent'],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export const createPhaseField = (client: PipefyClient | null, phaseId: string, field: Row) =>
|
|
161
|
+
buildGraphql(
|
|
162
|
+
client,
|
|
163
|
+
'createPhaseField',
|
|
164
|
+
{
|
|
165
|
+
phase_id: phaseId,
|
|
166
|
+
type: field['type_id'],
|
|
167
|
+
uuid: field['uuid'],
|
|
168
|
+
connected_repo_id: field['connected_pipe_id'] ? String(field['connected_pipe_id']) : undefined,
|
|
169
|
+
...fieldArgs(field),
|
|
170
|
+
},
|
|
171
|
+
'phase_field { id internal_id uuid }',
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
export const updatePhaseField = (client: PipefyClient | null, field: Row) =>
|
|
175
|
+
buildGraphql(
|
|
176
|
+
client,
|
|
177
|
+
'updatePhaseField',
|
|
178
|
+
{ id: String(field['id']), uuid: field['uuid'], ...fieldArgs(field) },
|
|
179
|
+
'phase_field { id internal_id }',
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// ── database (table) fields ──────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A database's columns, which are `table_fields` on the write side even though a
|
|
186
|
+
* database's *payload* calls them `fields` like a pipe's.
|
|
187
|
+
*
|
|
188
|
+
* Every identifier below was measured, not inferred (VALIDATION.md §3g). The
|
|
189
|
+
* short version: **a table field is addressed by its slug** — for update, for
|
|
190
|
+
* delete and inside the order call. That is the exact opposite of
|
|
191
|
+
* `updatePhaseField`, whose `id` is the numeric internal id, and sending the
|
|
192
|
+
* internal id or the uuid here is refused with `Field not found with id: <the id
|
|
193
|
+
* that plainly exists>`.
|
|
194
|
+
*
|
|
195
|
+
* Two arguments this input does not have, both load-bearing:
|
|
196
|
+
*
|
|
197
|
+
* - **no `index`.** A create lands at the end and order is a separate
|
|
198
|
+
* `setTableFieldOrder` call.
|
|
199
|
+
* - **no `type`** on update. A type change would mean delete + create, which
|
|
200
|
+
* destroys that column in every record, so it is refused rather than done
|
|
201
|
+
* quietly — the same rule pipe fields already have.
|
|
202
|
+
*/
|
|
203
|
+
const tableFieldArgs = (field: Row): Row => ({
|
|
204
|
+
label: field['label'],
|
|
205
|
+
options: field['options'],
|
|
206
|
+
description: field['description'],
|
|
207
|
+
help: field['help'],
|
|
208
|
+
required: field['required'],
|
|
209
|
+
minimal_view: field['minimal_view'],
|
|
210
|
+
custom_validation: field['custom_validation'],
|
|
211
|
+
unique: field['unique'],
|
|
212
|
+
canConnectExisting: field['can_search_connected_cards'],
|
|
213
|
+
canConnectMultiples: field['can_connect_multiple_cards'],
|
|
214
|
+
canCreateNewConnected: field['can_create_connected_cards'],
|
|
215
|
+
childMustExistToFinishParent: field['child_must_exist_to_finish_parent'],
|
|
216
|
+
allChildrenMustBeDoneToFinishParent: field['all_children_must_be_done_to_finish_parent'],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** `table_id` accepts either the api id or the numeric internal id — both measured. */
|
|
220
|
+
export const createTableField = (client: PipefyClient | null, tableId: string | number, field: Row) =>
|
|
221
|
+
buildGraphql(
|
|
222
|
+
client,
|
|
223
|
+
'createTableField',
|
|
224
|
+
{
|
|
225
|
+
table_id: String(tableId),
|
|
226
|
+
type: field['type_id'],
|
|
227
|
+
connectedRepoId: field['connected_pipe_id'] ? String(field['connected_pipe_id']) : undefined,
|
|
228
|
+
...tableFieldArgs(field),
|
|
229
|
+
},
|
|
230
|
+
'table_field { id internal_id uuid }',
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `id` is the field's **slug**, and `table_id` is required alongside it.
|
|
235
|
+
*
|
|
236
|
+
* Measured: the numeric internal id and the uuid are both refused, and the slug
|
|
237
|
+
* both succeeds and actually changes the row — the "accepted but unchanged" case
|
|
238
|
+
* this project has been bitten by elsewhere does not happen here.
|
|
239
|
+
*/
|
|
240
|
+
export const updateTableField = (client: PipefyClient | null, tableId: string | number, field: Row) =>
|
|
241
|
+
buildGraphql(
|
|
242
|
+
client,
|
|
243
|
+
'updateTableField',
|
|
244
|
+
{ id: slugOf(field), table_id: String(tableId), ...tableFieldArgs(field) },
|
|
245
|
+
'table_field { id internal_id label }',
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/** Also the slug, also with `table_id`. Destroys that column in every record. */
|
|
249
|
+
export const deleteTableField = (client: PipefyClient | null, tableId: string | number, field: Row) =>
|
|
250
|
+
buildGraphql(
|
|
251
|
+
client,
|
|
252
|
+
'deleteTableField',
|
|
253
|
+
{ id: slugOf(field), table_id: String(tableId) },
|
|
254
|
+
'success',
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Set-replacing per table, and it **returns the applied order**.
|
|
259
|
+
*
|
|
260
|
+
* That return is worth having: unlike `setFieldConditionOrder`, which answers a
|
|
261
|
+
* 200 and nothing else, this one lets the step assert its own effect instead of
|
|
262
|
+
* trusting the status. `field_ids` are slugs.
|
|
263
|
+
*/
|
|
264
|
+
export const setTableFieldOrder = (
|
|
265
|
+
client: PipefyClient | null,
|
|
266
|
+
tableId: string | number,
|
|
267
|
+
fields: Row[],
|
|
268
|
+
) =>
|
|
269
|
+
buildGraphql(
|
|
270
|
+
client,
|
|
271
|
+
'setTableFieldOrder',
|
|
272
|
+
{ table_id: String(tableId), field_ids: fields.map(slugOf) },
|
|
273
|
+
'table_fields { id index }',
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The field's slug, which the payload stores in `slug` and the table API returns
|
|
278
|
+
* as `id`. Falls back to the numeric id only so a request fails loudly rather
|
|
279
|
+
* than silently addressing nothing.
|
|
280
|
+
*/
|
|
281
|
+
const slugOf = (field: Row): string => String(field['slug'] ?? field['id'] ?? '');
|
|
282
|
+
|
|
283
|
+
/** Needs the pipe uuid as well as the field id — recipe steps 42/43. */
|
|
284
|
+
/**
|
|
285
|
+
* `deletePhaseField.id` is the field's **slug**, not the numeric id the payload
|
|
286
|
+
* stores as `id`.
|
|
287
|
+
*
|
|
288
|
+
* A field has three identifiers and the mutations disagree about which one they
|
|
289
|
+
* take: `updatePhaseField.id` accepts the numeric internal id, `archiveField`
|
|
290
|
+
* wants the uuid, and this one wants the slug. Sent the numeric id it answers
|
|
291
|
+
* `Field not found with id: <that id>` — about a field that is plainly there,
|
|
292
|
+
* which reads like a stale workspace rather than a wrong argument.
|
|
293
|
+
*
|
|
294
|
+
* Measured on a throwaway field: delete by internal id was refused with "Phase
|
|
295
|
+
* field not found with id", delete by slug succeeded. The slug is in the payload
|
|
296
|
+
* already, so nothing needs looking up.
|
|
297
|
+
*/
|
|
298
|
+
export const deletePhaseField = (client: PipefyClient | null, field: Row, pipeUuid: string | null) => {
|
|
299
|
+
const slug = field['slug'];
|
|
300
|
+
return buildGraphql(
|
|
301
|
+
client,
|
|
302
|
+
'deletePhaseField',
|
|
303
|
+
{
|
|
304
|
+
/**
|
|
305
|
+
* Falls back to the id only when the row has no slug, which happens for a
|
|
306
|
+
* field the workspace authored and never pulled. That request will fail,
|
|
307
|
+
* and failing loudly beats deleting something else.
|
|
308
|
+
*/
|
|
309
|
+
id: String(slug ?? field['id'] ?? ''),
|
|
310
|
+
pipeUuid: pipeUuid ?? undefined,
|
|
311
|
+
},
|
|
312
|
+
'success',
|
|
313
|
+
);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* `ArchiveFieldInput` is `{ uuid: String! }` — the field's **uuid**, not its id.
|
|
318
|
+
* Sent as an id, the mutation is rejected; the required-field check now catches
|
|
319
|
+
* that at plan time, which is how the real shape came to light.
|
|
320
|
+
*/
|
|
321
|
+
export const archiveField = (client: PipefyClient | null, field: Row, archived: boolean) =>
|
|
322
|
+
buildGraphql(
|
|
323
|
+
client,
|
|
324
|
+
archived ? 'archiveField' : 'unarchiveField',
|
|
325
|
+
{ uuid: field['uuid'] ? String(field['uuid']) : undefined },
|
|
326
|
+
'clientMutationId',
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
// ── labels ───────────────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* `CreateLabelInput` has **both** `pipe_id` and `table_id`, and a database's
|
|
333
|
+
* labels go in the second.
|
|
334
|
+
*
|
|
335
|
+
* Worth spelling out because the obvious probe fails in a misleading way: sending
|
|
336
|
+
* a database id as `pipe_id` is refused with `Pipe not found with id: <the
|
|
337
|
+
* database id>`, which reads like "databases have no labels" and is not. With
|
|
338
|
+
* `table_id` it works, and so do `updateLabel` and `deleteLabel`, both of which
|
|
339
|
+
* take only the label's own id regardless of which kind of repo owns it. Measured
|
|
340
|
+
* as full CRUD on a throwaway database.
|
|
341
|
+
*/
|
|
342
|
+
export const createLabel = (
|
|
343
|
+
client: PipefyClient | null,
|
|
344
|
+
repoId: number | string,
|
|
345
|
+
label: Row,
|
|
346
|
+
kind: 'pipe' | 'table' = 'pipe',
|
|
347
|
+
) =>
|
|
348
|
+
buildGraphql(
|
|
349
|
+
client,
|
|
350
|
+
'createLabel',
|
|
351
|
+
{
|
|
352
|
+
...(kind === 'table' ? { table_id: String(repoId) } : { pipe_id: String(repoId) }),
|
|
353
|
+
name: label['name'],
|
|
354
|
+
color: label['color'],
|
|
355
|
+
uuid: label['uuid'],
|
|
356
|
+
},
|
|
357
|
+
'label { id name }',
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
export const updateLabel = (client: PipefyClient | null, label: Row) =>
|
|
361
|
+
buildGraphql(client, 'updateLabel', { id: String(label['id']), name: label['name'], color: label['color'] }, 'label { id }');
|
|
362
|
+
|
|
363
|
+
export const deleteLabel = (client: PipefyClient | null, id: string | number) =>
|
|
364
|
+
buildGraphql(client, 'deleteLabel', { id: String(id) }, 'success');
|
|
365
|
+
|
|
366
|
+
// ── webhooks ─────────────────────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
export const createWebhook = (client: PipefyClient | null, pipeId: number, hook: Row) =>
|
|
369
|
+
buildGraphql(
|
|
370
|
+
client,
|
|
371
|
+
'createWebhook',
|
|
372
|
+
{
|
|
373
|
+
pipe_id: String(pipeId),
|
|
374
|
+
name: hook['name'],
|
|
375
|
+
url: hook['url'],
|
|
376
|
+
email: hook['email'],
|
|
377
|
+
actions: hook['actions'],
|
|
378
|
+
headers: hook['headers'],
|
|
379
|
+
filters: hook['filters'],
|
|
380
|
+
},
|
|
381
|
+
'webhook { id name url }',
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
export const updateWebhook = (client: PipefyClient | null, hook: Row) =>
|
|
385
|
+
buildGraphql(
|
|
386
|
+
client,
|
|
387
|
+
'updateWebhook',
|
|
388
|
+
{ id: String(hook['id']), name: hook['name'], url: hook['url'], email: hook['email'], actions: hook['actions'], headers: hook['headers'] },
|
|
389
|
+
'webhook { id }',
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
export const deleteWebhook = (client: PipefyClient | null, id: string | number) =>
|
|
393
|
+
buildGraphql(client, 'deleteWebhook', { id: String(id) }, 'success');
|
|
394
|
+
|
|
395
|
+
// ── field conditions ─────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Recipe step 85 transforms the read shape into the create input: strip
|
|
399
|
+
* `label`, `phase` → `phaseId`, `phaseField` → `phaseFieldId`, keep
|
|
400
|
+
* `whenEvaluator` and `actionId`, keep the condition with its expressions.
|
|
401
|
+
* Step 109 does the same for update but emits `phase_id`.
|
|
402
|
+
*/
|
|
403
|
+
export const fieldConditionInput = (fc: { [k: string]: unknown; actions?: Row[]; condition?: Row | null }, style: 'create' | 'update'): Row => {
|
|
404
|
+
const cond = fc['condition'] as Row | null | undefined;
|
|
405
|
+
|
|
406
|
+
const base: Row = {
|
|
407
|
+
name: fc['name'],
|
|
408
|
+
actions: (fc['actions'] ?? []).map((a) => ({
|
|
409
|
+
actionId: a['action_id'],
|
|
410
|
+
phaseFieldId: String(a['field_id'] ?? ''),
|
|
411
|
+
whenEvaluator: a['when_evaluator'],
|
|
412
|
+
})),
|
|
413
|
+
/**
|
|
414
|
+
* Always sent, even with nothing in it.
|
|
415
|
+
*
|
|
416
|
+
* An unconditional hide — one action, no expressions — is a real and useful
|
|
417
|
+
* shape: it is how a system field that only an automation writes is kept off
|
|
418
|
+
* a human form, and real pipes carry exactly that, stored as
|
|
419
|
+
* `expressions_structure: []`. But the mutation will not accept it unless the
|
|
420
|
+
* `condition` key is present:
|
|
421
|
+
*
|
|
422
|
+
* condition omitted -> "Something went wrong"
|
|
423
|
+
* condition: null -> "Something went wrong"
|
|
424
|
+
* condition: {expressions: []} -> works
|
|
425
|
+
*
|
|
426
|
+
* `condition` is not NON_NULL on the input type, so nothing in the schema
|
|
427
|
+
* says this, and the endpoint's answer names neither the field nor the
|
|
428
|
+
* reason. Measured by creating one condition per shape on a throwaway phase.
|
|
429
|
+
*/
|
|
430
|
+
/**
|
|
431
|
+
* The same builder automations use.
|
|
432
|
+
*
|
|
433
|
+
* This path had its own copy of the expression mapping and passed
|
|
434
|
+
* `structure_id` straight through, so an authored expression that did not
|
|
435
|
+
* carry one was rejected with "Invalid input: Structure can't be blank" —
|
|
436
|
+
* `expressions_structure` names structure ids, and there was nothing to name.
|
|
437
|
+
* One builder means the derivation cannot be fixed in one place and missed in
|
|
438
|
+
* the other, which is exactly what happened.
|
|
439
|
+
*/
|
|
440
|
+
condition: conditionInput(cond ?? {}),
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
if (style === 'create') {
|
|
444
|
+
base['phaseId'] = String(fc['phase_id'] ?? '');
|
|
445
|
+
} else {
|
|
446
|
+
base['id'] = String(fc['id'] ?? '');
|
|
447
|
+
base['phase_id'] = String(fc['phase_id'] ?? '');
|
|
448
|
+
}
|
|
449
|
+
return base;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
export const createFieldCondition = (client: PipefyClient | null, fc: Row) =>
|
|
453
|
+
buildGraphql(client, 'createFieldCondition', fieldConditionInput(fc, 'create'), 'fieldCondition { id }');
|
|
454
|
+
|
|
455
|
+
export const updateFieldCondition = (client: PipefyClient | null, fc: Row) =>
|
|
456
|
+
buildGraphql(client, 'updateFieldCondition', fieldConditionInput(fc, 'update'), 'fieldCondition { id }');
|
|
457
|
+
|
|
458
|
+
export const deleteFieldCondition = (client: PipefyClient | null, id: string | number) =>
|
|
459
|
+
buildGraphql(client, 'deleteFieldCondition', { id: String(id) }, 'success');
|
|
460
|
+
|
|
461
|
+
export const setFieldConditionOrder = (client: PipefyClient | null, phaseId: string | number, ids: Array<string | number>) =>
|
|
462
|
+
buildGraphql(
|
|
463
|
+
client,
|
|
464
|
+
'setFieldConditionOrder',
|
|
465
|
+
{ phaseId: String(phaseId), fieldConditionIds: ids.map(String) },
|
|
466
|
+
'clientMutationId',
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
// ── pipe relations ───────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Every boolean on the relation inputs is NON_NULL, and `autoFillFieldEnabled`
|
|
473
|
+
* has no column in the snapshot payload at all — so the flags are defaulted
|
|
474
|
+
* here rather than passed through as the null the payload would hand over.
|
|
475
|
+
*/
|
|
476
|
+
/**
|
|
477
|
+
* The mutation and the payload spell the same three flags differently.
|
|
478
|
+
*
|
|
479
|
+
* `createPipeRelation` takes `canCreateNewItems`, `canConnectExistingItems` and
|
|
480
|
+
* `canConnectMultipleItems`; the row the payload hands back calls them
|
|
481
|
+
* `can_create_connected_cards`, `can_search_connected_cards` and
|
|
482
|
+
* `can_connect_multiple_cards` — items in one, cards in the other. So a relation
|
|
483
|
+
* read from a pull carries none of the keys this builder was looking for, every
|
|
484
|
+
* one of them fell through to its `?? default`, and updating a relation's *name*
|
|
485
|
+
* silently switched off "can create new items" on it. Which is what happened to
|
|
486
|
+
* the FDE → CS handoff.
|
|
487
|
+
*
|
|
488
|
+
* Both spellings are read, mutation-side first, so a hand-authored row and a
|
|
489
|
+
* pulled row behave identically. A missing value still falls back, but now only
|
|
490
|
+
* when the value is genuinely absent.
|
|
491
|
+
*/
|
|
492
|
+
const relationFlags = (r: Row): Row => {
|
|
493
|
+
const flag = (...keys: string[]) => {
|
|
494
|
+
for (const k of keys) if (r[k] !== undefined && r[k] !== null) return Boolean(r[k]);
|
|
495
|
+
return undefined;
|
|
496
|
+
};
|
|
497
|
+
return {
|
|
498
|
+
canCreateNewItems: flag('can_create_new_items', 'can_create_connected_cards') ?? false,
|
|
499
|
+
canConnectExistingItems: flag('can_connect_existing_items', 'can_search_connected_cards') ?? true,
|
|
500
|
+
canConnectMultipleItems: flag('can_connect_multiple_items', 'can_connect_multiple_cards') ?? false,
|
|
501
|
+
childMustExistToMoveParent: flag('child_must_exist_to_move_parent') ?? false,
|
|
502
|
+
childMustExistToFinishParent: flag('child_must_exist_to_finish_parent') ?? false,
|
|
503
|
+
allChildrenMustBeDoneToMoveParent: flag('all_children_must_be_done_to_move_parent') ?? false,
|
|
504
|
+
allChildrenMustBeDoneToFinishParent: flag('all_children_must_be_done_to_finish_parent') ?? false,
|
|
505
|
+
autoFillFieldEnabled: flag('auto_fill_field_enabled') ?? false,
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
export const createPipeRelation = (client: PipefyClient | null, r: Row) =>
|
|
510
|
+
buildGraphql(
|
|
511
|
+
client,
|
|
512
|
+
'createPipeRelation',
|
|
513
|
+
{ parentId: String(r['parent_id']), childId: String(r['child_id']), name: r['name'], ...relationFlags(r) },
|
|
514
|
+
'pipeRelation { id }',
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
export const updatePipeRelation = (client: PipefyClient | null, r: Row) =>
|
|
518
|
+
buildGraphql(
|
|
519
|
+
client,
|
|
520
|
+
'updatePipeRelation',
|
|
521
|
+
{ id: String(r['id']), name: r['name'], ...relationFlags(r) },
|
|
522
|
+
'pipeRelation { id }',
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
export const deletePipeRelation = (client: PipefyClient | null, id: string | number) =>
|
|
526
|
+
buildGraphql(client, 'deletePipeRelation', { id: String(id) }, 'success');
|
|
527
|
+
|
|
528
|
+
// ── repo root ────────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* `updatePipe.title_field_id` takes the field's **uuid** or its slug — never the
|
|
532
|
+
* numeric id, which is exactly what the payload column holds. Probed against the
|
|
533
|
+
* live endpoint: the numeric id is rejected with "Field not found with id", while
|
|
534
|
+
* the uuid and `request_title` are both accepted. So the numeric id is
|
|
535
|
+
* translated, and the argument is dropped rather than guessed when it cannot be.
|
|
536
|
+
*/
|
|
537
|
+
export const updatePipe = (client: PipefyClient | null, repo: Row, fieldUuidById: Record<string, string> = {}) =>
|
|
538
|
+
buildGraphql(
|
|
539
|
+
client,
|
|
540
|
+
'updatePipe',
|
|
541
|
+
{
|
|
542
|
+
id: String(repo['id']),
|
|
543
|
+
name: repo['name'],
|
|
544
|
+
noun: repo['noun'],
|
|
545
|
+
icon: repo['icon'],
|
|
546
|
+
color: repo['color'],
|
|
547
|
+
public: repo['public'],
|
|
548
|
+
title_field_id: repo['title_field_id'] ? fieldUuidById[String(repo['title_field_id'])] : undefined,
|
|
549
|
+
expiration_time_by_unit: repo['expiration_time'],
|
|
550
|
+
only_admin_can_remove_cards: repo['only_admin_can_remove_cards'],
|
|
551
|
+
only_assignees_can_edit_cards: repo['only_assignees_can_edit_cards'],
|
|
552
|
+
/**
|
|
553
|
+
* `anyone_can_create_card`, `create_card_label` and `description` used to
|
|
554
|
+
* be sent here. None of the three is in `UpdatePipeInput`, so all three were
|
|
555
|
+
* dropped by the introspection filter and the mutation reported success
|
|
556
|
+
* without applying them. They are refused at plan time now, by
|
|
557
|
+
* `repo.update.settableColumns`, which is the honest answer.
|
|
558
|
+
*/
|
|
559
|
+
},
|
|
560
|
+
'pipe { id name }',
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The database row. A different mutation from `updatePipe`, with a different
|
|
565
|
+
* settable set — so a database's root edit must not be routed through the pipe
|
|
566
|
+
* builder, where every table-only column would be silently dropped.
|
|
567
|
+
*
|
|
568
|
+
* Two arguments disagree about identifiers, and both were measured
|
|
569
|
+
* (VALIDATION.md §3g):
|
|
570
|
+
*
|
|
571
|
+
* - **`title_field_id` takes the field's uuid or its slug**, never the numeric
|
|
572
|
+
* internal id, which is refused with `Field not found with id:`. The slug is
|
|
573
|
+
* used here because a database's fields are addressed by slug everywhere else.
|
|
574
|
+
* - **`summary_attributes` takes the numeric internal id** — or a repo attribute
|
|
575
|
+
* slug such as `created_at` — and refuses a *field* slug with `Something went
|
|
576
|
+
* wrong`. It is the one argument on this input that wants the internal form,
|
|
577
|
+
* which is why it is spelled out rather than sharing a helper.
|
|
578
|
+
*
|
|
579
|
+
* `labels` and `members` are absent from `UpdateTableInput` — they can only be
|
|
580
|
+
* set when the database is created — so neither is sent, and the registry says so.
|
|
581
|
+
*/
|
|
582
|
+
export const updateTable = (
|
|
583
|
+
client: PipefyClient | null,
|
|
584
|
+
repo: Row,
|
|
585
|
+
fieldSlugById: Record<string, string> = {},
|
|
586
|
+
) =>
|
|
587
|
+
buildGraphql(
|
|
588
|
+
client,
|
|
589
|
+
'updateTable',
|
|
590
|
+
{
|
|
591
|
+
id: String(repo['id']),
|
|
592
|
+
name: repo['name'],
|
|
593
|
+
noun: repo['noun'],
|
|
594
|
+
icon: repo['icon'],
|
|
595
|
+
color: repo['color'],
|
|
596
|
+
public: repo['public'],
|
|
597
|
+
description: repo['description'],
|
|
598
|
+
authorization: repo['authorization'],
|
|
599
|
+
create_record_button_label: repo['create_card_label'],
|
|
600
|
+
title_field_id: repo['title_field_id'] ? fieldSlugById[String(repo['title_field_id'])] : undefined,
|
|
601
|
+
},
|
|
602
|
+
'table { id internal_id name }',
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* `UpdateRepoPreferencesInput` is `{ repoUuid: ID!, hiddenStartFormAttributes,
|
|
607
|
+
* hiddenTopButtons }` and nothing else — keyed on the repo **uuid**, and covering
|
|
608
|
+
* only those two arrays. Everything an earlier version sent here (start form
|
|
609
|
+
* title, custom sorting, the AI toggles) simply has no input.
|
|
610
|
+
*
|
|
611
|
+
* The three preferences that *are* settable elsewhere go through
|
|
612
|
+
* `updatePipe.preferences`, which is `RepoPreferenceInput`: findable,
|
|
613
|
+
* inboxEmailEnabled, mainTabViews.
|
|
614
|
+
*/
|
|
615
|
+
export const updateRepoPreferences = (client: PipefyClient | null, repoUuid: string | null, prefs: Row) =>
|
|
616
|
+
buildGraphql(
|
|
617
|
+
client,
|
|
618
|
+
'updateRepoPreferences',
|
|
619
|
+
{
|
|
620
|
+
repoUuid: repoUuid ?? undefined,
|
|
621
|
+
hiddenStartFormAttributes: prefs['hidden_start_form_attributes'],
|
|
622
|
+
hiddenTopButtons: prefs['hidden_top_buttons'],
|
|
623
|
+
},
|
|
624
|
+
'clientMutationId',
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
/** The preference subset `updatePipe` accepts, as `RepoPreferenceInput`. */
|
|
628
|
+
export const updatePipePreferences = (client: PipefyClient | null, repoId: number, prefs: Row) =>
|
|
629
|
+
buildGraphql(
|
|
630
|
+
client,
|
|
631
|
+
'updatePipe',
|
|
632
|
+
{
|
|
633
|
+
id: String(repoId),
|
|
634
|
+
preferences: {
|
|
635
|
+
findable: prefs['findable'],
|
|
636
|
+
inboxEmailEnabled: prefs['inbox_email_enabled'],
|
|
637
|
+
mainTabViews: prefs['main_tab_views'],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
'pipe { id }',
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
/** The public form, as `updatePipe.publicFormSettings`. */
|
|
644
|
+
export const updatePublicForm = (client: PipefyClient | null, repoId: number, form: Row) =>
|
|
645
|
+
buildGraphql(
|
|
646
|
+
client,
|
|
647
|
+
'updatePipe',
|
|
648
|
+
{
|
|
649
|
+
id: String(repoId),
|
|
650
|
+
publicFormSettings: {
|
|
651
|
+
title: form['title'],
|
|
652
|
+
description: form['description'],
|
|
653
|
+
submitButtonText: form['submit_button_text'],
|
|
654
|
+
afterSubmitMessage: form['after_submit_message'],
|
|
655
|
+
brandColor: form['brand_color'],
|
|
656
|
+
backgroundColor: form['background_color'],
|
|
657
|
+
displayPipefyLogo: form['display_pipefy_logo'],
|
|
658
|
+
showSubmitAnotherResponseButton: form['show_submit_another_response_button'],
|
|
659
|
+
submitterEmailCollectionEnabled: form['submitter_email_collection_enabled'],
|
|
660
|
+
reuseLastSubmissionResponse: form['reuse_last_submission_response'],
|
|
661
|
+
},
|
|
662
|
+
},
|
|
663
|
+
'pipe { id }',
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
// ── automations (internal endpoint) ──────────────────────────────────────────
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Variables for the internal `createAutomation`, built from a workspace
|
|
670
|
+
* automation node. The recipes send exactly this variable set (step 121).
|
|
671
|
+
*/
|
|
672
|
+
/**
|
|
673
|
+
* The payload's snake_case params to the names the *input* types accept.
|
|
674
|
+
*
|
|
675
|
+
* Introspected from `AutomationActionParamsInput` and `AutomationEventParamsInput`,
|
|
676
|
+
* because the casing is genuinely inconsistent — `card_id`, `to_phase_id`,
|
|
677
|
+
* `field_map` and `fields_map_order` are snake_case while `taskParams`,
|
|
678
|
+
* `aiParams`, `slaParams`, `httpMethod` and the `authentication*` keys are camel.
|
|
679
|
+
* No generic camelizer produces this, which is why it is a table.
|
|
680
|
+
*
|
|
681
|
+
* Getting it wrong is not cosmetic: sending `task_params` had the recreate half
|
|
682
|
+
* of an automation update rejected *after* the delete half had succeeded, which
|
|
683
|
+
* destroyed a live automation. Every `send_a_task` automation in a pipe goes
|
|
684
|
+
* through this path.
|
|
685
|
+
*/
|
|
686
|
+
const ACTION_PARAM_INPUT_NAMES: Record<string, string> = {
|
|
687
|
+
task_params: 'taskParams',
|
|
688
|
+
ai_params: 'aiParams',
|
|
689
|
+
ai_behavior_params: 'aiBehaviorParams',
|
|
690
|
+
sla_params: 'slaParams',
|
|
691
|
+
http_method: 'httpMethod',
|
|
692
|
+
authentication_key: 'authenticationKey',
|
|
693
|
+
authentication_value: 'authenticationValue',
|
|
694
|
+
authentication_add_to: 'authenticationAddTo',
|
|
695
|
+
authentication_type: 'authenticationType',
|
|
696
|
+
oauth2_uuid: 'oauth2Uuid',
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
const EVENT_PARAM_INPUT_NAMES: Record<string, string> = {
|
|
700
|
+
from_phase_id: 'fromPhaseId',
|
|
701
|
+
in_phase_id: 'inPhaseId',
|
|
702
|
+
kind_of_sla: 'kindOfSla',
|
|
703
|
+
trigger_automation_id: 'triggerAutomationId',
|
|
704
|
+
trigger_field_ids: 'triggerFieldIds',
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/** Nested inputs have their own names again: AutomationAiParamsInput is camel. */
|
|
708
|
+
const NESTED_PARAM_INPUT_NAMES: Record<string, string> = {
|
|
709
|
+
field_ids: 'fieldIds',
|
|
710
|
+
skills_ids: 'skillsIds',
|
|
711
|
+
mcp_server_config: 'mcpServerConfig',
|
|
712
|
+
start_hour: 'startHour',
|
|
713
|
+
end_hour: 'endHour',
|
|
714
|
+
// AiBehaviorParamsInput / AiBehaviorActionAttributesInput / AiBehaviorMetadataInput —
|
|
715
|
+
// same reasoning: the wire shape is camel two and three levels down from
|
|
716
|
+
// action_params too, confirmed against captured createAiAgent/updateAiAgent
|
|
717
|
+
// traffic (src/pipefy/agents.ts has the read side of the same shape).
|
|
718
|
+
data_source_ids: 'dataSourceIds',
|
|
719
|
+
referenced_field_ids: 'referencedFieldIds',
|
|
720
|
+
provider_id: 'providerId',
|
|
721
|
+
system_provider_id: 'systemProviderId',
|
|
722
|
+
capabilities_attributes: 'capabilitiesAttributes',
|
|
723
|
+
capability_type: 'capabilityType',
|
|
724
|
+
actions_attributes: 'actionsAttributes',
|
|
725
|
+
action_type: 'actionType',
|
|
726
|
+
reference_id: 'referenceId',
|
|
727
|
+
destination_phase_id: 'destinationPhaseId',
|
|
728
|
+
pipe_id: 'pipeId',
|
|
729
|
+
table_id: 'tableId',
|
|
730
|
+
email_template_id: 'emailTemplateId',
|
|
731
|
+
allow_template_modifications: 'allowTemplateModifications',
|
|
732
|
+
mcp_server_id: 'mcpServerId',
|
|
733
|
+
tool_name: 'toolName',
|
|
734
|
+
tool_inputs: 'toolInputs',
|
|
735
|
+
fields_attributes: 'fieldsAttributes',
|
|
736
|
+
field_id: 'fieldId',
|
|
737
|
+
input_mode: 'inputMode',
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
/** Keys the read path adds that no input accepts. */
|
|
741
|
+
const NOT_INPUT_KEYS = new Set(['has_authentication_value', 'hasAuthenticationValue', 'phase']);
|
|
742
|
+
|
|
743
|
+
const toInputNames = (value: unknown, top: Record<string, string>): unknown => {
|
|
744
|
+
if (Array.isArray(value)) return value.map((v) => toInputNames(v, NESTED_PARAM_INPUT_NAMES));
|
|
745
|
+
if (value && typeof value === 'object') {
|
|
746
|
+
const out: Row = {};
|
|
747
|
+
for (const [k, v] of Object.entries(value as Row)) {
|
|
748
|
+
if (NOT_INPUT_KEYS.has(k)) continue;
|
|
749
|
+
if (v === undefined) continue;
|
|
750
|
+
const name = top[k] ?? NESTED_PARAM_INPUT_NAMES[k] ?? k;
|
|
751
|
+
out[name] = toInputNames(v, NESTED_PARAM_INPUT_NAMES);
|
|
752
|
+
}
|
|
753
|
+
return out;
|
|
754
|
+
}
|
|
755
|
+
return value;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* A condition, in the shape the API actually binds.
|
|
760
|
+
*
|
|
761
|
+
* `expressions_structure` is a nested list of **structure ids**, not indices: it
|
|
762
|
+
* is how the API links each expression into the and/or tree. An expression with
|
|
763
|
+
* no `structure_id` therefore has nothing for the structure to point at, and the
|
|
764
|
+
* whole condition is dropped — mutation successful, `expressions: []` stored, no
|
|
765
|
+
* error anywhere. Measured on create and on update, with four different
|
|
766
|
+
* operation and structure shapes; only the variant carrying `structure_id`
|
|
767
|
+
* survived.
|
|
768
|
+
*
|
|
769
|
+
* Pulled automations already carry server-assigned structure ids, which is why
|
|
770
|
+
* this never showed up on a round trip and only bit hand-authored conditions.
|
|
771
|
+
* So the id is derived from position when absent and left alone when present,
|
|
772
|
+
* and the structure's leaves are stringified because that is how the API stores
|
|
773
|
+
* and compares them.
|
|
774
|
+
*/
|
|
775
|
+
const structureIds = (v: unknown): unknown =>
|
|
776
|
+
Array.isArray(v) ? v.map(structureIds) : v === null || v === undefined ? v : String(v);
|
|
777
|
+
|
|
778
|
+
export const conditionInput = (cond: Row): Record<string, unknown> => {
|
|
779
|
+
const rows = (cond['expressions'] as Row[] | undefined) ?? [];
|
|
780
|
+
const expressions = rows.map((e, i) => ({
|
|
781
|
+
field_address: String(e['field_address'] ?? e['field_id'] ?? ''),
|
|
782
|
+
operation: e['operation'],
|
|
783
|
+
value: e['value'],
|
|
784
|
+
structure_id: e['structure_id'] ?? String(i),
|
|
785
|
+
}));
|
|
786
|
+
const declared = cond['expressions_structure'];
|
|
787
|
+
return {
|
|
788
|
+
expressions,
|
|
789
|
+
/**
|
|
790
|
+
* With no structure declared, a single and-group over every expression is the
|
|
791
|
+
* only reading that keeps them all bound. An empty structure drops them.
|
|
792
|
+
*/
|
|
793
|
+
expressions_structure: declared
|
|
794
|
+
? structureIds(declared)
|
|
795
|
+
: expressions.length
|
|
796
|
+
? [expressions.map((e) => String(e.structure_id))]
|
|
797
|
+
: [],
|
|
798
|
+
};
|
|
799
|
+
};
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Automations, on the **public** endpoint.
|
|
803
|
+
*
|
|
804
|
+
* `createAutomation`, `updateAutomation` and `deleteAutomation` are all on the
|
|
805
|
+
* public API and documented. This tool used the internal endpoint for all three,
|
|
806
|
+
* inherited from the Change Migrator recipes and never re-checked — which meant
|
|
807
|
+
* automations were the one entity written through an unversioned endpoint with no
|
|
808
|
+
* introspection and no compatibility contract, for no reason.
|
|
809
|
+
*
|
|
810
|
+
* Probed to confirm before switching: a create and a delete both succeeded
|
|
811
|
+
* against the public endpoint.
|
|
812
|
+
*/
|
|
813
|
+
export const createAutomation = (client: PipefyClient | null, auto: Row & { condition?: Row | null; field_maps?: Row[] }) =>
|
|
814
|
+
buildGraphql(client, 'createAutomation', automationVariables(auto), 'automation { id name event_id action_id }');
|
|
815
|
+
|
|
816
|
+
export const updateAutomation = (client: PipefyClient | null, auto: Row & { condition?: Row | null; field_maps?: Row[] }) =>
|
|
817
|
+
buildGraphql(
|
|
818
|
+
client,
|
|
819
|
+
'updateAutomation',
|
|
820
|
+
{ id: String(auto['id'] ?? ''), ...automationVariables(auto) },
|
|
821
|
+
'automation { id name }',
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
export const deleteAutomation = (client: PipefyClient | null, id: string | number) =>
|
|
825
|
+
buildGraphql(client, 'deleteAutomation', { id: String(id) }, 'success');
|
|
826
|
+
|
|
827
|
+
export const automationVariables = (auto: Row & { condition?: Row | null; field_maps?: Row[] }): Record<string, unknown> => {
|
|
828
|
+
const actionParams = {
|
|
829
|
+
...((toInputNames(auto['action_params'] ?? {}, ACTION_PARAM_INPUT_NAMES) as Row) ?? {}),
|
|
830
|
+
};
|
|
831
|
+
const fieldMaps = auto['field_maps'] ?? [];
|
|
832
|
+
|
|
833
|
+
if (Array.isArray(fieldMaps) && fieldMaps.length) {
|
|
834
|
+
actionParams['field_map'] = (fieldMaps as Row[]).map((m) => ({
|
|
835
|
+
fieldId: String(m['field_id'] ?? ''),
|
|
836
|
+
value: m['value'] ?? '',
|
|
837
|
+
inputMode: m['input_mode'] ?? undefined,
|
|
838
|
+
}));
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* `fields_map_order` is not derived from `field_map` by the API — it is a
|
|
842
|
+
* separate argument, and both the automation editor and the run engine read
|
|
843
|
+
* it (not `field_map`) to know which fields the automation writes. Omitting
|
|
844
|
+
* it here does not error and does not no-op quietly either: the automation
|
|
845
|
+
* is created, `field_map` holds exactly what was authored, and the pipe
|
|
846
|
+
* still writes nothing and shows no fields configured, because nothing named
|
|
847
|
+
* them in filling order. Every observed working automation stores the same
|
|
848
|
+
* ids here as `field_map`, in the same order, so that is the default unless
|
|
849
|
+
* the row states its own.
|
|
850
|
+
*/
|
|
851
|
+
actionParams['fields_map_order'] = Array.isArray(auto['fields_map_order']) && (auto['fields_map_order'] as unknown[]).length
|
|
852
|
+
? auto['fields_map_order']
|
|
853
|
+
: (fieldMaps as Row[]).map((m) => String(m['field_id'] ?? ''));
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const cond = auto['condition'] as Row | null | undefined;
|
|
857
|
+
const condition = cond ? conditionInput(cond) : undefined;
|
|
858
|
+
|
|
859
|
+
return dropUndefined({
|
|
860
|
+
name: auto['name'],
|
|
861
|
+
/**
|
|
862
|
+
* `active` was absent from this builder, so disabling an automation in the
|
|
863
|
+
* workspace silently did nothing: the mutation succeeded and the automation
|
|
864
|
+
* stayed on. Measured by toggling it and reading the row back.
|
|
865
|
+
*/
|
|
866
|
+
active: typeof auto['active'] === 'boolean' ? auto['active'] : undefined,
|
|
867
|
+
action_id: auto['action_id'],
|
|
868
|
+
event_id: auto['event_id'],
|
|
869
|
+
action_repo_id: auto['action_repo_id'] ? String(auto['action_repo_id']) : undefined,
|
|
870
|
+
event_repo_id: auto['event_repo_id'] ? String(auto['event_repo_id']) : undefined,
|
|
871
|
+
event_params: toInputNames(auto['event_params'] ?? {}, EVENT_PARAM_INPUT_NAMES),
|
|
872
|
+
action_params: Object.keys(actionParams).length ? actionParams : undefined,
|
|
873
|
+
condition,
|
|
874
|
+
scheduler_frequency: auto['scheduler_frequency'] ?? undefined,
|
|
875
|
+
searchFor: auto['search_for'] && Object.keys(auto['search_for'] as Row).length ? auto['search_for'] : undefined,
|
|
876
|
+
schedulerCron: auto['scheduler_cron'] ?? undefined,
|
|
877
|
+
responseSchema: auto['response_schema'] ?? undefined,
|
|
878
|
+
}) as Record<string, unknown>;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
// ── AI agents ────────────────────────────────────────────────────────────────
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* `createAiAgent` / `updateAiAgent` — confirmed against real Pipefy web UI
|
|
885
|
+
* traffic (captured, not guessed) and reproduced directly against the API.
|
|
886
|
+
*
|
|
887
|
+
* Both take the **entire** agent in one call: `AiAgentInput` carries the
|
|
888
|
+
* agent's own fields plus its complete `behaviors` list, existing ones
|
|
889
|
+
* unchanged (by `id`) alongside whatever is new or edited — there is no
|
|
890
|
+
* per-behavior mutation. Omitting an existing behavior deletes it, which is
|
|
891
|
+
* why every caller here sends the workspace's current, complete list for
|
|
892
|
+
* that agent, never a partial one.
|
|
893
|
+
*
|
|
894
|
+
* The behavior shape nested inside is *not* `UpdateAutomationInput` — it is
|
|
895
|
+
* its own input type, confirmed camelCase at the top (`eventId`, `actionId`,
|
|
896
|
+
* `eventParams`, `active`) where the automation mutations are snake_case.
|
|
897
|
+
* Everything below that first level matches what `pipefy/agents.ts` already
|
|
898
|
+
* reads back, aliased the same way in reverse.
|
|
899
|
+
*/
|
|
900
|
+
|
|
901
|
+
/** A fresh v4 UUID for an `actionsAttributes[]` entry the author added with none. */
|
|
902
|
+
const referenceIdFor = (a: Row): string => String(a['reference_id'] ?? a['referenceId'] ?? randomUUID());
|
|
903
|
+
|
|
904
|
+
/** One behavior, in the shape `AiAgentInput.behaviors[]` actually takes. */
|
|
905
|
+
export const behaviorInput = (b: Row & { condition?: Row | null }): Row => {
|
|
906
|
+
const params = (b['action_params'] as Row | undefined)?.['ai_behavior_params'] as Row | undefined;
|
|
907
|
+
const actionsAttributes = ((params?.['actions_attributes'] as Row[] | undefined) ?? []).map((a) => ({
|
|
908
|
+
...a,
|
|
909
|
+
reference_id: referenceIdFor(a),
|
|
910
|
+
}));
|
|
911
|
+
|
|
912
|
+
const aiBehaviorParams = toInputNames(
|
|
913
|
+
{ ...(params ?? {}), actions_attributes: actionsAttributes },
|
|
914
|
+
NESTED_PARAM_INPUT_NAMES,
|
|
915
|
+
);
|
|
916
|
+
|
|
917
|
+
const cond = b['condition'];
|
|
918
|
+
return dropUndefined({
|
|
919
|
+
id: b['id'] ? String(b['id']) : undefined,
|
|
920
|
+
name: b['name'],
|
|
921
|
+
active: typeof b['active'] === 'boolean' ? b['active'] : true,
|
|
922
|
+
actionId: b['action_id'] ?? 'ai_behavior',
|
|
923
|
+
eventId: b['event_id'],
|
|
924
|
+
eventParams: toInputNames(b['event_params'] ?? {}, EVENT_PARAM_INPUT_NAMES),
|
|
925
|
+
condition: cond ? conditionInput(cond as Row) : null,
|
|
926
|
+
actionParams: { aiBehaviorParams },
|
|
927
|
+
});
|
|
928
|
+
};
|
|
929
|
+
|
|
930
|
+
/** The agent itself plus its complete behaviors list, as `AiAgentInput`. */
|
|
931
|
+
export const agentInput = (agent: Row, behaviors: Row[]): Row =>
|
|
932
|
+
dropUndefined({
|
|
933
|
+
repoUuid: agent['repoUuid'] ? String(agent['repoUuid']) : undefined,
|
|
934
|
+
name: agent['name'],
|
|
935
|
+
instruction: agent['instruction'] ?? '',
|
|
936
|
+
dataSourceIds: agent['dataSourceIds'] ?? [],
|
|
937
|
+
behaviors: behaviors.map((b) => behaviorInput(b as Row & { condition?: Row | null })),
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
export const createAiAgent = (client: PipefyClient | null, agent: Row, behaviors: Row[]) =>
|
|
941
|
+
buildGraphql(client, 'createAiAgent', { agent: agentInput(agent, behaviors) }, 'agent { uuid name }');
|
|
942
|
+
|
|
943
|
+
export const updateAiAgent = (client: PipefyClient | null, uuid: string, agent: Row, behaviors: Row[]) =>
|
|
944
|
+
buildGraphql(
|
|
945
|
+
client,
|
|
946
|
+
'updateAiAgent',
|
|
947
|
+
{ uuid, agent: agentInput(agent, behaviors) },
|
|
948
|
+
'agent { uuid name behaviors { id } }',
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
export const deleteAiAgent = (client: PipefyClient | null, uuid: string) =>
|
|
952
|
+
buildGraphql(client, 'deleteAiAgent', { uuid }, 'success');
|
|
953
|
+
|
|
954
|
+
export const warnUnverified = (what: string) =>
|
|
955
|
+
warn(`${what}: shape could not be verified against this schema — the step is marked unverified and will report its own failure`);
|