@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.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. 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`);