@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

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 (162) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Field types, as a **fallback only**.
3
+ *
4
+ * The authority is `REFERENCE.md` / `.ppc/reference.json`, read from the API on
5
+ * the last pull and checked in `validate/reference.ts`. This list is what remains
6
+ * when a workspace has no reference yet, and it is kept a warning because a
7
+ * hand-maintained list of what an API accepts goes stale invisibly: this one
8
+ * carried `time_range`, which the API does not offer, and omitted
9
+ * `dynamic_content`, which it does — warning about a valid type and passing an
10
+ * invalid one, with nothing to reveal either.
11
+ */
12
+ const FIELD_TYPES = new Set([
13
+ 'assignee_select',
14
+ 'attachment',
15
+ 'checklist_horizontal',
16
+ 'checklist_vertical',
17
+ 'cnpj',
18
+ 'connector',
19
+ 'cpf',
20
+ 'currency',
21
+ 'date',
22
+ 'datetime',
23
+ 'due_date',
24
+ 'dynamic_content',
25
+ 'email',
26
+ 'id',
27
+ 'label_select',
28
+ 'long_text',
29
+ 'number',
30
+ 'phone',
31
+ 'radio_horizontal',
32
+ 'radio_vertical',
33
+ 'select',
34
+ 'short_text',
35
+ 'statement',
36
+ 'time',
37
+ 'time_range',
38
+ ]);
39
+ const isNewEntity = (row) => row['id'] === null || row['id'] === undefined || row['_new'] === true;
40
+ const requireString = (row, key, entity, file, findings) => {
41
+ const v = row[key];
42
+ if (typeof v !== 'string' || !v.trim()) {
43
+ findings.push({ severity: 'error', file, entity, message: `${key} is required and must be a non-empty string` });
44
+ }
45
+ };
46
+ export const validateSchema = (tree) => {
47
+ const findings = [];
48
+ // ── repo ───────────────────────────────────────────────────────────────────
49
+ if (!tree.repo['id']) {
50
+ findings.push({ severity: 'error', file: 'pipe.json', entity: 'repo', message: 'id is missing — the repo row must keep its server-owned id' });
51
+ }
52
+ requireString(tree.repo, 'name', 'repo', 'pipe.json', findings);
53
+ // ── the start form is a phase, and must stay one ───────────────────────────
54
+ const startForm = tree.phases.find((p) => Number(p['index']) === 0);
55
+ if (!startForm) {
56
+ findings.push({
57
+ severity: 'error',
58
+ entity: 'phases',
59
+ message: 'no phase at index 0 — the start form is a real phase and must exist',
60
+ fix: 'restore the phase file whose index is 0, or re-pull',
61
+ });
62
+ }
63
+ const seenPhaseIndex = new Map();
64
+ const seenUuid = new Map();
65
+ const seenFieldId = new Map();
66
+ const seenFieldLabel = new Map();
67
+ const checkUuid = (row, entity, file, required) => {
68
+ const uuid = row['uuid'];
69
+ if (uuid === undefined || uuid === null || uuid === '') {
70
+ if (required && isNewEntity(row)) {
71
+ findings.push({
72
+ severity: 'error',
73
+ file,
74
+ entity,
75
+ message: `new ${entity} "${String(row['name'] ?? row['label'] ?? '?')}" has no uuid`,
76
+ fix: `add a fresh v4 uuid — ${entity} is one of the four entity types identified by uuid`,
77
+ });
78
+ }
79
+ return;
80
+ }
81
+ if (typeof uuid !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) {
82
+ findings.push({ severity: 'error', file, entity, message: `uuid "${String(uuid)}" is not a v4 uuid` });
83
+ return;
84
+ }
85
+ const prior = seenUuid.get(uuid);
86
+ if (prior) {
87
+ findings.push({
88
+ severity: 'error',
89
+ file,
90
+ entity,
91
+ message: `uuid ${uuid} is used twice (also in ${prior})`,
92
+ fix: 'two entities can never share a uuid — generate a new one',
93
+ });
94
+ }
95
+ else {
96
+ seenUuid.set(uuid, file);
97
+ }
98
+ };
99
+ for (const phase of tree.phases) {
100
+ const file = `phases/… ${String(phase['name'] ?? '')}`;
101
+ requireString(phase, 'name', 'phases', file, findings);
102
+ checkUuid(phase, 'phases', file, true);
103
+ const index = Number(phase['index']);
104
+ if (!Number.isFinite(index)) {
105
+ findings.push({ severity: 'error', file, entity: 'phases', message: 'index must be a number' });
106
+ }
107
+ else {
108
+ const prior = seenPhaseIndex.get(index);
109
+ if (prior) {
110
+ findings.push({
111
+ severity: 'error',
112
+ file,
113
+ entity: 'phases',
114
+ message: `index ${index} is used by two phases (also "${prior}")`,
115
+ fix: 'phase indexes must be unique — note that reordering phases is not applicable at all',
116
+ });
117
+ }
118
+ seenPhaseIndex.set(index, String(phase['name'] ?? ''));
119
+ }
120
+ if (!Array.isArray(phase.fields)) {
121
+ findings.push({ severity: 'error', file, entity: 'phases', message: 'fields must be an array' });
122
+ continue;
123
+ }
124
+ for (const f of phase.fields) {
125
+ const ffile = `${file} / ${String(f['label'] ?? '')}`;
126
+ requireString(f, 'label', 'fields', ffile, findings);
127
+ checkUuid(f, 'fields', ffile, true);
128
+ const type = String(f['type_id'] ?? '');
129
+ if (!type) {
130
+ findings.push({ severity: 'error', file: ffile, entity: 'fields', message: 'type_id is required' });
131
+ }
132
+ else if (!FIELD_TYPES.has(type)) {
133
+ findings.push({
134
+ severity: 'warning',
135
+ file: ffile,
136
+ entity: 'fields',
137
+ message: `type_id "${type}" is not one this build has seen`,
138
+ fix: 'run pipe pull to read the accepted list from the API — see REFERENCE.md',
139
+ });
140
+ }
141
+ if (type === 'connector' && !f['connected_pipe_id']) {
142
+ findings.push({
143
+ severity: 'error',
144
+ file: ffile,
145
+ entity: 'fields',
146
+ message: 'a connector field must name the repo it connects to in connected_pipe_id',
147
+ });
148
+ }
149
+ if ((type === 'select' || type === 'radio_vertical' || type === 'radio_horizontal') && !Array.isArray(f['options'])) {
150
+ findings.push({ severity: 'error', file: ffile, entity: 'fields', message: `a ${type} field needs an options array` });
151
+ }
152
+ const id = f['id'];
153
+ if (id !== null && id !== undefined) {
154
+ const prior = seenFieldId.get(String(id));
155
+ if (prior) {
156
+ findings.push({
157
+ severity: 'error',
158
+ file: ffile,
159
+ entity: 'fields',
160
+ message: `field id ${String(id)} appears twice (also in ${prior})`,
161
+ });
162
+ }
163
+ seenFieldId.set(String(id), ffile);
164
+ }
165
+ /**
166
+ * A duplicate label is not refused on create — `createPhaseField` happily
167
+ * minted two fields both labelled "Classification" (one in another phase,
168
+ * one in the same phase) and just disambiguated the slug underneath,
169
+ * confirmed live against pipe 307321096. It bites later instead:
170
+ * `UpdatePhaseFieldInput.label` is non-null, so *every* `updatePhaseField`
171
+ * call resends the field's current label whether or not it changed, and
172
+ * that is where a pipe-wide duplicate surfaces as "Field label has already
173
+ * been taken" — the exact error this rule exists to catch before an apply
174
+ * reaches that step. Scoped to the whole repo, not the phase, matching the
175
+ * already-measured table rule (a table field label is unique per table).
176
+ */
177
+ const fieldLabel = String(f['label'] ?? '').trim();
178
+ if (fieldLabel) {
179
+ const priorLabel = seenFieldLabel.get(fieldLabel);
180
+ if (priorLabel) {
181
+ findings.push({
182
+ severity: 'error',
183
+ file: ffile,
184
+ entity: 'fields',
185
+ message: `label "${fieldLabel}" is also used by another field in this pipe (${priorLabel})`,
186
+ fix: 'updatePhaseField resends the current label on every call and the API rejects a repeat with "Field label has already been taken" — rename one of the two before either is updated for any reason',
187
+ });
188
+ }
189
+ else {
190
+ seenFieldLabel.set(fieldLabel, ffile);
191
+ }
192
+ }
193
+ }
194
+ if (!Array.isArray(phase.jump_target_ids)) {
195
+ findings.push({ severity: 'error', file, entity: 'phase_jumps', message: 'jump_target_ids must be an array of phase ids' });
196
+ }
197
+ }
198
+ for (const a of tree.automations) {
199
+ const file = `automations/${String(a['name'] ?? '')}`;
200
+ requireString(a, 'name', 'automations', file, findings);
201
+ requireString(a, 'event_id', 'automations', file, findings);
202
+ requireString(a, 'action_id', 'automations', file, findings);
203
+ checkUuid(a, 'automations', file, true);
204
+ if (a['_incomplete']) {
205
+ findings.push({
206
+ severity: 'warning',
207
+ file,
208
+ entity: 'automations',
209
+ message: 'this automation was read without its action_params — the internal API did not return its full definition',
210
+ fix: 'do not edit it; re-pull once the internal endpoint answers, or it will be recreated with an empty configuration',
211
+ });
212
+ }
213
+ }
214
+ for (const fc of tree.field_conditions) {
215
+ const file = `field-conditions/${String(fc['name'] ?? '')}`;
216
+ requireString(fc, 'name', 'field_conditions', file, findings);
217
+ if (!fc['phase_id']) {
218
+ findings.push({
219
+ severity: 'error',
220
+ file,
221
+ entity: 'field_conditions',
222
+ message: 'phase_id is required',
223
+ fix: 'for a phase being created in the same edit, use the placeholder "%{_new:<phase uuid>}"',
224
+ });
225
+ }
226
+ if (!Array.isArray(fc.actions) || fc.actions.length === 0) {
227
+ findings.push({ severity: 'error', file, entity: 'field_conditions', message: 'a field condition with no actions does nothing' });
228
+ }
229
+ /**
230
+ * A condition must not test the field it shows or hides.
231
+ *
232
+ * "Hide this field while it is empty" reads sensibly and is a trap: the
233
+ * field's visibility depends on its own value, so nobody can ever fill it,
234
+ * and the phase form failed to open until the condition was deleted.
235
+ *
236
+ * The API is no help here. Measured on a live pipe: it stores a
237
+ * self-referential condition, an `is_empty` operation and even
238
+ * `not_a_real_operation` without complaint, so nothing surfaces until the
239
+ * form is opened by a person. Every field condition read from real pipes
240
+ * points its expressions at one field and its actions at another.
241
+ *
242
+ * Reported as an error rather than a warning because the damage is to a form
243
+ * humans use, and the fix — test a different field, or drop the expression
244
+ * for an unconditional hide — is always available.
245
+ */
246
+ const targets = new Set((Array.isArray(fc.actions) ? fc.actions : [])
247
+ .map((a) => String(a['field_id'] ?? ''))
248
+ .filter(Boolean));
249
+ const cond = fc['condition'];
250
+ for (const e of (cond?.['expressions'] ?? [])) {
251
+ /**
252
+ * `field_address` is not always a field: real conditions address
253
+ * `assignees` and `created_by` too. Only a numeric id can collide with an
254
+ * action target, and a placeholder is compared as written because a field
255
+ * created in this edit is referenced the same way on both sides.
256
+ */
257
+ const addr = String(e['field_address'] ?? e['field_id'] ?? '');
258
+ if (!addr || !targets.has(addr))
259
+ continue;
260
+ findings.push({
261
+ severity: 'error',
262
+ file,
263
+ entity: 'field_conditions',
264
+ message: `the condition tests field ${addr}, which is also the field it shows or hides`,
265
+ fix: 'test a different field, or remove the expression so the action applies unconditionally',
266
+ });
267
+ }
268
+ }
269
+ for (const l of tree.labels)
270
+ checkUuid(l, 'labels', 'labels.json', true);
271
+ for (const w of tree.webhooks) {
272
+ requireString(w, 'url', 'webhooks', 'webhooks.json', findings);
273
+ if (typeof w['url'] === 'string' && !/^https?:\/\//.test(w['url'])) {
274
+ findings.push({ severity: 'error', file: 'webhooks.json', entity: 'webhooks', message: `url "${w['url']}" is not http(s)` });
275
+ }
276
+ }
277
+ return findings;
278
+ };
@@ -0,0 +1,18 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /**
5
+ * The tool's identity as recorded in workspace.json and every run.json.
6
+ *
7
+ * The version is read from package.json, the same file `pipe --version` reads,
8
+ * because it used to be a literal `'0.1.0'` in three places that a release did
9
+ * not touch — so 0.1.1 stamped 0.1.0 on everything it wrote, and the one field
10
+ * meant to say which build produced a workspace said the wrong one.
11
+ *
12
+ * `dist/` mirrors `src/` one-for-one, so `../package.json` from here is the
13
+ * package root from either tree.
14
+ */
15
+ const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
16
+ export const PACKAGE_NAME = pkg.name;
17
+ export const TOOL_VERSION = pkg.version;
18
+ export const TOOL = { name: 'pipefy-process-coder', version: TOOL_VERSION };
@@ -0,0 +1,245 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { mkdirp, exists, writeJson, readJson } from "../util/fsx.js";
4
+ import { agentBaselineFile } from "./layout.js";
5
+ import { slug } from "../util/slug.js";
6
+ import { unfoldCondition } from "../codec/pack.js";
7
+ /**
8
+ * The `agents/` half of a workspace.
9
+ *
10
+ * AI agents are entities of the pipe but not of the snapshot payload (ROUTE-B-V2.md
11
+ * ask 5), so — same as `flows/` — they are read by a different query and kept
12
+ * beside `pipes/<id>--<name>/`, not folded into the payload tree:
13
+ *
14
+ * pipes/<id>--<name>/agents/
15
+ * _meta.json coverage, readAt, count
16
+ * <uuid>--<slug>/
17
+ * agent.json name, instruction, disabledAt, needReview, dataSourceIds
18
+ * behaviors/<slug>.json the agent's behaviors — each one an ordinary
19
+ * automation (action_id: "ai_behavior"), the
20
+ * same shape a file in automations/ has
21
+ *
22
+ * A behavior is not stored twice and not cross-referenced by id either: which
23
+ * agent it belongs to is not a column on the automation (the API only answers
24
+ * that question from the agent's side, `AiAgent.behaviors`), so *location on
25
+ * disk is the only record of the link*. `workspace/read.ts` tags each behavior
26
+ * it finds here with `_agent_dir` (which agent's folder it came from) when it
27
+ * folds them into `tree.automations`, and `workspace/write.ts` reads that tag
28
+ * back off to route the file to the same place — the same "folding is movement
29
+ * only" rule phases and fields already follow, just carried by a tag instead of
30
+ * a `phase_id` column, because automations do not have one to spare.
31
+ *
32
+ * A behavior's *content* comes only from `pipefy/agents.ts` — the snapshot
33
+ * payload's copy of the same automation is a stub (`behavior_uuid` plus two
34
+ * field-id lists, no `instruction`, no `actionsAttributes`), so a fresh pull
35
+ * discards it and uses this query's answer instead (`foldFreshBehaviors`,
36
+ * below), rather than merely tagging it in place the way `tagBehaviors` does
37
+ * for a tree that already has the real content — a live read-back after an
38
+ * apply, or an already-pulled workspace's own files.
39
+ *
40
+ * Writing a behavior is a single `createAiAgent` / `updateAiAgent` call, not
41
+ * `createAutomation` — confirmed against real Pipefy web UI traffic and
42
+ * reproduced directly. Both take the **entire** agent, including every
43
+ * existing behavior unchanged (by `id`) alongside whatever is new — omitting
44
+ * one deletes it. Not yet built in `pipe apply`.
45
+ */
46
+ export const AGENTS_DIR = 'agents';
47
+ /** The tag `workspace/read.ts` puts on a behavior automation, and `workspace/write.ts` reads back. */
48
+ export const AGENT_DIR_TAG = '_agent_dir';
49
+ export const agentsRoot = (repoDir) => join(repoDir, AGENTS_DIR);
50
+ export const agentDirName = (agent) => `${agent.uuid}--${slug(agent.name)}`;
51
+ /** The empty section, for a pipe whose agents could not be read. */
52
+ export const unknownAgents = (reason) => ({
53
+ meta: { coverage: 'unknown', readAt: new Date().toISOString(), reason, notes: [reason] },
54
+ agents: [],
55
+ });
56
+ /** id -> the directory of the agent it is a behavior of. */
57
+ export const behaviorDirMapFrom = (agents) => {
58
+ const dirById = new Map();
59
+ for (const agent of agents) {
60
+ const dir = agentDirName(agent);
61
+ for (const b of agent.behaviors)
62
+ dirById.set(String(b['id']), dir);
63
+ }
64
+ return dirById;
65
+ };
66
+ /**
67
+ * A fresh pull's `tree.automations` holds the *stub* the snapshot payload
68
+ * gives a behavior — `behavior_uuid` plus a couple of field-id lists, no
69
+ * `instruction`, no `actionsAttributes`. `pipefy/agents.ts` reads the real
70
+ * content of the same rows over a different query, so those stubs are
71
+ * dropped here and replaced with it — not merely tagged, the way an already-
72
+ * pulled workspace's behaviors are (see `tagBehaviors`) — because the stub
73
+ * has nothing worth keeping.
74
+ */
75
+ export const foldFreshBehaviors = (automations, agents) => {
76
+ const dirById = behaviorDirMapFrom(agents);
77
+ if (!dirById.size)
78
+ return automations;
79
+ const withoutStubs = automations.filter((a) => !dirById.has(String(a['id'])));
80
+ const behaviors = agents.flatMap((agent) => {
81
+ const dir = agentDirName(agent);
82
+ return agent.behaviors.map((b) => ({ ...b, [AGENT_DIR_TAG]: dir }));
83
+ });
84
+ return [...withoutStubs, ...behaviors];
85
+ };
86
+ /**
87
+ * The same replacement, done on the raw envelope instead of a `Tree` — for the
88
+ * *baseline* a fresh pull records, which is a payload envelope, not a tree.
89
+ *
90
+ * Skipping this leaves the baseline holding the snapshot's stub forever: the
91
+ * workspace files get the rich content (`foldFreshBehaviors`, above), the
92
+ * baseline does not, and `pipe diff` reports a phantom change on every single
93
+ * behavior on every single pull, forever, since the two can never agree.
94
+ *
95
+ * Reuses `codec/pack.ts`'s own unfolding — the exact inverse of what
96
+ * `codec/unpack.ts` does to build `condition` back up from `relations.conditions`
97
+ * / `relations.condition_expressions` — so a condition this replaces is found
98
+ * correctly on the next read, not silently dropped for want of a `condition_id`
99
+ * nothing points at.
100
+ */
101
+ export const foldBehaviorsIntoEnvelope = (env, agents) => {
102
+ const behaviorIds = new Set(agents.flatMap((a) => a.behaviors.map((b) => String(b['id']))));
103
+ if (!behaviorIds.size)
104
+ return env;
105
+ const relations = env.payload['relations'];
106
+ const isRemovedAutomation = (id) => behaviorIds.has(String(id));
107
+ const automations = (relations['automations'] ?? []).filter((a) => !isRemovedAutomation(a['id']));
108
+ const removedConditionIds = new Set((relations['conditions'] ?? [])
109
+ .filter((c) => c['source_type'] === 'Automation' && isRemovedAutomation(c['source_id']))
110
+ .map((c) => String(c['id'])));
111
+ const conditions = (relations['conditions'] ?? []).filter((c) => !(c['source_type'] === 'Automation' && isRemovedAutomation(c['source_id'])));
112
+ const conditionExpressions = (relations['condition_expressions'] ?? []).filter((e) => !removedConditionIds.has(String(e['condition_id'])));
113
+ const fieldMaps = (relations['field_maps'] ?? []).filter((m) => !(m['owner_type'] === 'Automation' && isRemovedAutomation(m['owner_id'])));
114
+ for (const agent of agents) {
115
+ for (const raw of agent.behaviors) {
116
+ const { condition, field_maps: behaviorFieldMaps, ...autoRow } = raw;
117
+ const id = Number(autoRow['id']);
118
+ automations.push({ ...autoRow, id });
119
+ if (condition)
120
+ unfoldCondition(condition, 'Automation', id, conditions, conditionExpressions);
121
+ for (const m of behaviorFieldMaps ?? [])
122
+ fieldMaps.push({ ...m, owner_type: 'Automation', owner_id: id });
123
+ }
124
+ }
125
+ return {
126
+ ...env,
127
+ payload: {
128
+ ...env.payload,
129
+ relations: {
130
+ ...relations,
131
+ automations,
132
+ conditions,
133
+ condition_expressions: conditionExpressions,
134
+ field_maps: fieldMaps,
135
+ },
136
+ },
137
+ };
138
+ };
139
+ /**
140
+ * The same map, rebuilt by reading whichever agent directories already exist
141
+ * on disk, rather than from a fresh fetch.
142
+ *
143
+ * Needed wherever a tree gets read back from the *live* pipe rather than from
144
+ * this workspace — `pipe apply`'s and `pipe verify --update`'s "adopt what the
145
+ * server has" step, both of which `unpack()` a fresh envelope with no agent
146
+ * folders in sight to derive `_agent_dir` from. Without this, adopting a
147
+ * verified apply would write every behavior straight into `automations/`,
148
+ * undoing the split on every single successful apply.
149
+ */
150
+ export const behaviorDirMapOnDisk = async (workspace) => {
151
+ const root = agentsRoot(workspace);
152
+ const dirById = new Map();
153
+ if (!(await exists(root)))
154
+ return dirById;
155
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
156
+ if (!entry.isDirectory())
157
+ continue;
158
+ const behaviorsDir = join(root, entry.name, 'behaviors');
159
+ if (!(await exists(behaviorsDir)))
160
+ continue;
161
+ for (const file of await fs.readdir(behaviorsDir)) {
162
+ if (!file.endsWith('.json'))
163
+ continue;
164
+ const row = await readJson(join(behaviorsDir, file));
165
+ if (row['id'] !== undefined && row['id'] !== null)
166
+ dirById.set(String(row['id']), entry.name);
167
+ }
168
+ }
169
+ return dirById;
170
+ };
171
+ /**
172
+ * Tag each automation that is some agent's behavior with the directory it
173
+ * belongs under, by id. `workspace/write.ts` reads the tag back off to route
174
+ * the file; `workspace/read.ts` sets the same tag itself, from wherever it
175
+ * actually found the file, so this is only needed for a tree that did not come
176
+ * from `readTree` — a fresh pull, or a live read-back adopted after an apply.
177
+ */
178
+ export const tagBehaviors = (automations, dirById) => {
179
+ if (!dirById.size)
180
+ return automations;
181
+ return automations.map((a) => {
182
+ const dir = dirById.get(String(a['id']));
183
+ return dir ? { ...a, [AGENT_DIR_TAG]: dir } : a;
184
+ });
185
+ };
186
+ // ── write ───────────────────────────────────────────────────────────────────
187
+ export const writeAgents = async (workspace, section) => {
188
+ const root = agentsRoot(workspace);
189
+ /**
190
+ * Rewritten wholesale, same reasoning as `flows/`: an agent deleted upstream
191
+ * must disappear from the workspace too. Behaviors are not touched here —
192
+ * `writeTree` owns `behaviors/`, and runs after this, so an agent's folder
193
+ * exists (even empty) by the time it needs somewhere to put them.
194
+ */
195
+ if (await exists(root))
196
+ await fs.rm(root, { recursive: true, force: true });
197
+ await mkdirp(root);
198
+ const written = [];
199
+ const put = async (path, value) => {
200
+ await writeJson(path, value);
201
+ written.push(path);
202
+ };
203
+ await put(join(root, '_meta.json'), section.meta);
204
+ for (const agent of section.agents) {
205
+ const dir = join(root, agentDirName(agent));
206
+ await mkdirp(join(dir, 'behaviors'));
207
+ const { behaviors: _behaviors, ...persisted } = agent;
208
+ await put(join(dir, 'agent.json'), persisted);
209
+ }
210
+ return written;
211
+ };
212
+ // ── read ────────────────────────────────────────────────────────────────────
213
+ export const readAgents = async (workspace) => {
214
+ const root = agentsRoot(workspace);
215
+ if (!(await exists(root)))
216
+ return null;
217
+ const meta = (await readJson(join(root, '_meta.json')).catch(() => null)) ?? {
218
+ coverage: 'unknown',
219
+ readAt: new Date().toISOString(),
220
+ notes: ['agents/_meta.json is missing, so coverage is unknown'],
221
+ };
222
+ const agents = [];
223
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
224
+ if (!entry.isDirectory())
225
+ continue;
226
+ const agentJson = join(root, entry.name, 'agent.json');
227
+ if (!(await exists(agentJson)))
228
+ continue;
229
+ const persisted = await readJson(agentJson);
230
+ agents.push({ ...persisted, behaviors: [] });
231
+ }
232
+ return { meta, agents };
233
+ };
234
+ /** Record what was just read as the baseline, so the next diff needs no network. */
235
+ export const writeAgentBaseline = (workspaceRoot, repoId, section) => writeJson(agentBaselineFile(workspaceRoot, repoId), section);
236
+ /** The recorded agent baseline, or null when this repo has never been pulled. */
237
+ export const readAgentBaseline = async (workspaceRoot, repoId) => {
238
+ try {
239
+ return await readJson(agentBaselineFile(workspaceRoot, repoId));
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ };
245
+ export const isNewAgent = (agent) => !agent['uuid'] || agent['_new'] === true;