@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,328 @@
1
+ import type { Row } from '../model/payload.ts';
2
+ import type { Tree } from '../model/tree.ts';
3
+
4
+ /**
5
+ * Structural validation — is this still a pipe?
6
+ *
7
+ * Hand-written rather than JSON-Schema-generated, for one reason: the payload
8
+ * has no `schema_version` and no documented column list (ROUTE-B-V2.md ask 9),
9
+ * so a generated schema would be as speculative as this and much harder to read
10
+ * when it rejects something. What is checked here is what actually breaks an
11
+ * apply.
12
+ */
13
+
14
+ export type Severity = 'error' | 'warning';
15
+
16
+ export type Finding = {
17
+ severity: Severity;
18
+ /** Workspace-relative file, when the finding belongs to one. */
19
+ file?: string;
20
+ entity: string;
21
+ message: string;
22
+ /** What to do about it, in one line. */
23
+ fix?: string;
24
+ /**
25
+ * The same finding is present in the baseline, so the pipe arrived this way
26
+ * and the edit did not cause it. Reported, never blocking: refusing to plan
27
+ * because of a pre-existing inconsistency would make some pipes uneditable.
28
+ */
29
+ preExisting?: boolean;
30
+ };
31
+
32
+ /**
33
+ * Field types, as a **fallback only**.
34
+ *
35
+ * The authority is `REFERENCE.md` / `.ppc/reference.json`, read from the API on
36
+ * the last pull and checked in `validate/reference.ts`. This list is what remains
37
+ * when a workspace has no reference yet, and it is kept a warning because a
38
+ * hand-maintained list of what an API accepts goes stale invisibly: this one
39
+ * carried `time_range`, which the API does not offer, and omitted
40
+ * `dynamic_content`, which it does — warning about a valid type and passing an
41
+ * invalid one, with nothing to reveal either.
42
+ */
43
+ const FIELD_TYPES = new Set([
44
+ 'assignee_select',
45
+ 'attachment',
46
+ 'checklist_horizontal',
47
+ 'checklist_vertical',
48
+ 'cnpj',
49
+ 'connector',
50
+ 'cpf',
51
+ 'currency',
52
+ 'date',
53
+ 'datetime',
54
+ 'due_date',
55
+ 'dynamic_content',
56
+ 'email',
57
+ 'id',
58
+ 'label_select',
59
+ 'long_text',
60
+ 'number',
61
+ 'phone',
62
+ 'radio_horizontal',
63
+ 'radio_vertical',
64
+ 'select',
65
+ 'short_text',
66
+ 'statement',
67
+ 'time',
68
+ 'time_range',
69
+ ]);
70
+
71
+ const isNewEntity = (row: Row) => row['id'] === null || row['id'] === undefined || row['_new'] === true;
72
+
73
+ const requireString = (row: Row, key: string, entity: string, file: string, findings: Finding[]) => {
74
+ const v = row[key];
75
+ if (typeof v !== 'string' || !v.trim()) {
76
+ findings.push({ severity: 'error', file, entity, message: `${key} is required and must be a non-empty string` });
77
+ }
78
+ };
79
+
80
+ export const validateSchema = (tree: Tree): Finding[] => {
81
+ const findings: Finding[] = [];
82
+
83
+ // ── repo ───────────────────────────────────────────────────────────────────
84
+ if (!tree.repo['id']) {
85
+ findings.push({ severity: 'error', file: 'pipe.json', entity: 'repo', message: 'id is missing — the repo row must keep its server-owned id' });
86
+ }
87
+ requireString(tree.repo, 'name', 'repo', 'pipe.json', findings);
88
+
89
+ // ── the start form is a phase, and must stay one ───────────────────────────
90
+ const startForm = tree.phases.find((p) => Number(p['index']) === 0);
91
+ if (!startForm) {
92
+ findings.push({
93
+ severity: 'error',
94
+ entity: 'phases',
95
+ message: 'no phase at index 0 — the start form is a real phase and must exist',
96
+ fix: 'restore the phase file whose index is 0, or re-pull',
97
+ });
98
+ }
99
+
100
+ const seenPhaseIndex = new Map<number, string>();
101
+ const seenUuid = new Map<string, string>();
102
+ const seenFieldId = new Map<string, string>();
103
+ const seenFieldLabel = new Map<string, string>();
104
+
105
+ const checkUuid = (row: Row, entity: string, file: string, required: boolean) => {
106
+ const uuid = row['uuid'];
107
+ if (uuid === undefined || uuid === null || uuid === '') {
108
+ if (required && isNewEntity(row)) {
109
+ findings.push({
110
+ severity: 'error',
111
+ file,
112
+ entity,
113
+ message: `new ${entity} "${String(row['name'] ?? row['label'] ?? '?')}" has no uuid`,
114
+ fix: `add a fresh v4 uuid — ${entity} is one of the four entity types identified by uuid`,
115
+ });
116
+ }
117
+ return;
118
+ }
119
+ 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)) {
120
+ findings.push({ severity: 'error', file, entity, message: `uuid "${String(uuid)}" is not a v4 uuid` });
121
+ return;
122
+ }
123
+ const prior = seenUuid.get(uuid);
124
+ if (prior) {
125
+ findings.push({
126
+ severity: 'error',
127
+ file,
128
+ entity,
129
+ message: `uuid ${uuid} is used twice (also in ${prior})`,
130
+ fix: 'two entities can never share a uuid — generate a new one',
131
+ });
132
+ } else {
133
+ seenUuid.set(uuid, file);
134
+ }
135
+ };
136
+
137
+ for (const phase of tree.phases) {
138
+ const file = `phases/… ${String(phase['name'] ?? '')}`;
139
+ requireString(phase, 'name', 'phases', file, findings);
140
+ checkUuid(phase, 'phases', file, true);
141
+
142
+ const index = Number(phase['index']);
143
+ if (!Number.isFinite(index)) {
144
+ findings.push({ severity: 'error', file, entity: 'phases', message: 'index must be a number' });
145
+ } else {
146
+ const prior = seenPhaseIndex.get(index);
147
+ if (prior) {
148
+ findings.push({
149
+ severity: 'error',
150
+ file,
151
+ entity: 'phases',
152
+ message: `index ${index} is used by two phases (also "${prior}")`,
153
+ fix: 'phase indexes must be unique — note that reordering phases is not applicable at all',
154
+ });
155
+ }
156
+ seenPhaseIndex.set(index, String(phase['name'] ?? ''));
157
+ }
158
+
159
+ if (!Array.isArray(phase.fields)) {
160
+ findings.push({ severity: 'error', file, entity: 'phases', message: 'fields must be an array' });
161
+ continue;
162
+ }
163
+
164
+ for (const f of phase.fields) {
165
+ const ffile = `${file} / ${String(f['label'] ?? '')}`;
166
+ requireString(f, 'label', 'fields', ffile, findings);
167
+ checkUuid(f, 'fields', ffile, true);
168
+
169
+ const type = String(f['type_id'] ?? '');
170
+ if (!type) {
171
+ findings.push({ severity: 'error', file: ffile, entity: 'fields', message: 'type_id is required' });
172
+ } else if (!FIELD_TYPES.has(type)) {
173
+ findings.push({
174
+ severity: 'warning',
175
+ file: ffile,
176
+ entity: 'fields',
177
+ message: `type_id "${type}" is not one this build has seen`,
178
+ fix: 'run pipe pull to read the accepted list from the API — see REFERENCE.md',
179
+ });
180
+ }
181
+
182
+ if (type === 'connector' && !f['connected_pipe_id']) {
183
+ findings.push({
184
+ severity: 'error',
185
+ file: ffile,
186
+ entity: 'fields',
187
+ message: 'a connector field must name the repo it connects to in connected_pipe_id',
188
+ });
189
+ }
190
+
191
+ if ((type === 'select' || type === 'radio_vertical' || type === 'radio_horizontal') && !Array.isArray(f['options'])) {
192
+ findings.push({ severity: 'error', file: ffile, entity: 'fields', message: `a ${type} field needs an options array` });
193
+ }
194
+
195
+ const id = f['id'];
196
+ if (id !== null && id !== undefined) {
197
+ const prior = seenFieldId.get(String(id));
198
+ if (prior) {
199
+ findings.push({
200
+ severity: 'error',
201
+ file: ffile,
202
+ entity: 'fields',
203
+ message: `field id ${String(id)} appears twice (also in ${prior})`,
204
+ });
205
+ }
206
+ seenFieldId.set(String(id), ffile);
207
+ }
208
+
209
+ /**
210
+ * A duplicate label is not refused on create — `createPhaseField` happily
211
+ * minted two fields both labelled "Classification" (one in another phase,
212
+ * one in the same phase) and just disambiguated the slug underneath,
213
+ * confirmed live against pipe 307321096. It bites later instead:
214
+ * `UpdatePhaseFieldInput.label` is non-null, so *every* `updatePhaseField`
215
+ * call resends the field's current label whether or not it changed, and
216
+ * that is where a pipe-wide duplicate surfaces as "Field label has already
217
+ * been taken" — the exact error this rule exists to catch before an apply
218
+ * reaches that step. Scoped to the whole repo, not the phase, matching the
219
+ * already-measured table rule (a table field label is unique per table).
220
+ */
221
+ const fieldLabel = String(f['label'] ?? '').trim();
222
+ if (fieldLabel) {
223
+ const priorLabel = seenFieldLabel.get(fieldLabel);
224
+ if (priorLabel) {
225
+ findings.push({
226
+ severity: 'error',
227
+ file: ffile,
228
+ entity: 'fields',
229
+ message: `label "${fieldLabel}" is also used by another field in this pipe (${priorLabel})`,
230
+ 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',
231
+ });
232
+ } else {
233
+ seenFieldLabel.set(fieldLabel, ffile);
234
+ }
235
+ }
236
+ }
237
+
238
+ if (!Array.isArray(phase.jump_target_ids)) {
239
+ findings.push({ severity: 'error', file, entity: 'phase_jumps', message: 'jump_target_ids must be an array of phase ids' });
240
+ }
241
+ }
242
+
243
+ for (const a of tree.automations) {
244
+ const file = `automations/${String(a['name'] ?? '')}`;
245
+ requireString(a, 'name', 'automations', file, findings);
246
+ requireString(a, 'event_id', 'automations', file, findings);
247
+ requireString(a, 'action_id', 'automations', file, findings);
248
+ checkUuid(a, 'automations', file, true);
249
+ if (a['_incomplete']) {
250
+ findings.push({
251
+ severity: 'warning',
252
+ file,
253
+ entity: 'automations',
254
+ message: 'this automation was read without its action_params — the internal API did not return its full definition',
255
+ fix: 'do not edit it; re-pull once the internal endpoint answers, or it will be recreated with an empty configuration',
256
+ });
257
+ }
258
+ }
259
+
260
+ for (const fc of tree.field_conditions) {
261
+ const file = `field-conditions/${String(fc['name'] ?? '')}`;
262
+ requireString(fc, 'name', 'field_conditions', file, findings);
263
+ if (!fc['phase_id']) {
264
+ findings.push({
265
+ severity: 'error',
266
+ file,
267
+ entity: 'field_conditions',
268
+ message: 'phase_id is required',
269
+ fix: 'for a phase being created in the same edit, use the placeholder "%{_new:<phase uuid>}"',
270
+ });
271
+ }
272
+ if (!Array.isArray(fc.actions) || fc.actions.length === 0) {
273
+ findings.push({ severity: 'error', file, entity: 'field_conditions', message: 'a field condition with no actions does nothing' });
274
+ }
275
+
276
+ /**
277
+ * A condition must not test the field it shows or hides.
278
+ *
279
+ * "Hide this field while it is empty" reads sensibly and is a trap: the
280
+ * field's visibility depends on its own value, so nobody can ever fill it,
281
+ * and the phase form failed to open until the condition was deleted.
282
+ *
283
+ * The API is no help here. Measured on a live pipe: it stores a
284
+ * self-referential condition, an `is_empty` operation and even
285
+ * `not_a_real_operation` without complaint, so nothing surfaces until the
286
+ * form is opened by a person. Every field condition read from real pipes
287
+ * points its expressions at one field and its actions at another.
288
+ *
289
+ * Reported as an error rather than a warning because the damage is to a form
290
+ * humans use, and the fix — test a different field, or drop the expression
291
+ * for an unconditional hide — is always available.
292
+ */
293
+ const targets = new Set(
294
+ (Array.isArray(fc.actions) ? fc.actions : [])
295
+ .map((a) => String((a as Row)['field_id'] ?? ''))
296
+ .filter(Boolean),
297
+ );
298
+ const cond = fc['condition'] as Row | null | undefined;
299
+ for (const e of ((cond?.['expressions'] as Row[] | undefined) ?? [])) {
300
+ /**
301
+ * `field_address` is not always a field: real conditions address
302
+ * `assignees` and `created_by` too. Only a numeric id can collide with an
303
+ * action target, and a placeholder is compared as written because a field
304
+ * created in this edit is referenced the same way on both sides.
305
+ */
306
+ const addr = String(e['field_address'] ?? e['field_id'] ?? '');
307
+ if (!addr || !targets.has(addr)) continue;
308
+ findings.push({
309
+ severity: 'error',
310
+ file,
311
+ entity: 'field_conditions',
312
+ message: `the condition tests field ${addr}, which is also the field it shows or hides`,
313
+ fix: 'test a different field, or remove the expression so the action applies unconditionally',
314
+ });
315
+ }
316
+ }
317
+
318
+ for (const l of tree.labels) checkUuid(l, 'labels', 'labels.json', true);
319
+
320
+ for (const w of tree.webhooks) {
321
+ requireString(w, 'url', 'webhooks', 'webhooks.json', findings);
322
+ if (typeof w['url'] === 'string' && !/^https?:\/\//.test(w['url'])) {
323
+ findings.push({ severity: 'error', file: 'webhooks.json', entity: 'webhooks', message: `url "${w['url']}" is not http(s)` });
324
+ }
325
+ }
326
+
327
+ return findings;
328
+ };
@@ -0,0 +1,290 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { mkdirp, exists, writeJson, readJson } from '../util/fsx.ts';
4
+ import { agentBaselineFile } from './layout.ts';
5
+ import { slug } from '../util/slug.ts';
6
+ import { unfoldCondition } from '../codec/pack.ts';
7
+ import type { Row, PayloadEnvelope } from '../model/payload.ts';
8
+ import type { ConditionNode } from '../model/tree.ts';
9
+ import type { AgentRow } from '../pipefy/agents.ts';
10
+
11
+ /**
12
+ * The `agents/` half of a workspace.
13
+ *
14
+ * AI agents are entities of the pipe but not of the snapshot payload (ROUTE-B-V2.md
15
+ * ask 5), so — same as `flows/` — they are read by a different query and kept
16
+ * beside `pipes/<id>--<name>/`, not folded into the payload tree:
17
+ *
18
+ * pipes/<id>--<name>/agents/
19
+ * _meta.json coverage, readAt, count
20
+ * <uuid>--<slug>/
21
+ * agent.json name, instruction, disabledAt, needReview, dataSourceIds
22
+ * behaviors/<slug>.json the agent's behaviors — each one an ordinary
23
+ * automation (action_id: "ai_behavior"), the
24
+ * same shape a file in automations/ has
25
+ *
26
+ * A behavior is not stored twice and not cross-referenced by id either: which
27
+ * agent it belongs to is not a column on the automation (the API only answers
28
+ * that question from the agent's side, `AiAgent.behaviors`), so *location on
29
+ * disk is the only record of the link*. `workspace/read.ts` tags each behavior
30
+ * it finds here with `_agent_dir` (which agent's folder it came from) when it
31
+ * folds them into `tree.automations`, and `workspace/write.ts` reads that tag
32
+ * back off to route the file to the same place — the same "folding is movement
33
+ * only" rule phases and fields already follow, just carried by a tag instead of
34
+ * a `phase_id` column, because automations do not have one to spare.
35
+ *
36
+ * A behavior's *content* comes only from `pipefy/agents.ts` — the snapshot
37
+ * payload's copy of the same automation is a stub (`behavior_uuid` plus two
38
+ * field-id lists, no `instruction`, no `actionsAttributes`), so a fresh pull
39
+ * discards it and uses this query's answer instead (`foldFreshBehaviors`,
40
+ * below), rather than merely tagging it in place the way `tagBehaviors` does
41
+ * for a tree that already has the real content — a live read-back after an
42
+ * apply, or an already-pulled workspace's own files.
43
+ *
44
+ * Writing a behavior is a single `createAiAgent` / `updateAiAgent` call, not
45
+ * `createAutomation` — confirmed against real Pipefy web UI traffic and
46
+ * reproduced directly. Both take the **entire** agent, including every
47
+ * existing behavior unchanged (by `id`) alongside whatever is new — omitting
48
+ * one deletes it. Not yet built in `pipe apply`.
49
+ */
50
+
51
+ export const AGENTS_DIR = 'agents';
52
+ /** The tag `workspace/read.ts` puts on a behavior automation, and `workspace/write.ts` reads back. */
53
+ export const AGENT_DIR_TAG = '_agent_dir';
54
+
55
+ export type AgentsMeta = {
56
+ coverage: 'complete' | 'unknown';
57
+ readAt: string;
58
+ agents?: number;
59
+ reason?: string;
60
+ notes: string[];
61
+ };
62
+
63
+ export type AgentsSection = {
64
+ meta: AgentsMeta;
65
+ /**
66
+ * `behaviors` is populated fresh from `pipefy/agents.ts` right after a pull
67
+ * (for `foldFreshBehaviors` to fold into `tree.automations`) and is not
68
+ * itself persisted to `agent.json` — the files under that agent's
69
+ * `behaviors/` already hold the same content, more durably. Reading an
70
+ * already-pulled workspace back returns it empty.
71
+ */
72
+ agents: AgentRow[];
73
+ };
74
+
75
+ export const agentsRoot = (repoDir: string) => join(repoDir, AGENTS_DIR);
76
+
77
+ export const agentDirName = (agent: Pick<AgentRow, 'uuid' | 'name'>) => `${agent.uuid}--${slug(agent.name)}`;
78
+
79
+ /** The empty section, for a pipe whose agents could not be read. */
80
+ export const unknownAgents = (reason: string): AgentsSection => ({
81
+ meta: { coverage: 'unknown', readAt: new Date().toISOString(), reason, notes: [reason] },
82
+ agents: [],
83
+ });
84
+
85
+ /** id -> the directory of the agent it is a behavior of. */
86
+ export const behaviorDirMapFrom = (agents: AgentRow[]): Map<string, string> => {
87
+ const dirById = new Map<string, string>();
88
+ for (const agent of agents) {
89
+ const dir = agentDirName(agent);
90
+ for (const b of agent.behaviors) dirById.set(String(b['id']), dir);
91
+ }
92
+ return dirById;
93
+ };
94
+
95
+ /**
96
+ * A fresh pull's `tree.automations` holds the *stub* the snapshot payload
97
+ * gives a behavior — `behavior_uuid` plus a couple of field-id lists, no
98
+ * `instruction`, no `actionsAttributes`. `pipefy/agents.ts` reads the real
99
+ * content of the same rows over a different query, so those stubs are
100
+ * dropped here and replaced with it — not merely tagged, the way an already-
101
+ * pulled workspace's behaviors are (see `tagBehaviors`) — because the stub
102
+ * has nothing worth keeping.
103
+ */
104
+ export const foldFreshBehaviors = <T extends Row>(automations: T[], agents: AgentRow[]): T[] => {
105
+ const dirById = behaviorDirMapFrom(agents);
106
+ if (!dirById.size) return automations;
107
+ const withoutStubs = automations.filter((a) => !dirById.has(String(a['id'])));
108
+ const behaviors = agents.flatMap((agent) => {
109
+ const dir = agentDirName(agent);
110
+ return agent.behaviors.map((b) => ({ ...b, [AGENT_DIR_TAG]: dir }) as unknown as T);
111
+ });
112
+ return [...withoutStubs, ...behaviors];
113
+ };
114
+
115
+ /**
116
+ * The same replacement, done on the raw envelope instead of a `Tree` — for the
117
+ * *baseline* a fresh pull records, which is a payload envelope, not a tree.
118
+ *
119
+ * Skipping this leaves the baseline holding the snapshot's stub forever: the
120
+ * workspace files get the rich content (`foldFreshBehaviors`, above), the
121
+ * baseline does not, and `pipe diff` reports a phantom change on every single
122
+ * behavior on every single pull, forever, since the two can never agree.
123
+ *
124
+ * Reuses `codec/pack.ts`'s own unfolding — the exact inverse of what
125
+ * `codec/unpack.ts` does to build `condition` back up from `relations.conditions`
126
+ * / `relations.condition_expressions` — so a condition this replaces is found
127
+ * correctly on the next read, not silently dropped for want of a `condition_id`
128
+ * nothing points at.
129
+ */
130
+ export const foldBehaviorsIntoEnvelope = (env: PayloadEnvelope, agents: AgentRow[]): PayloadEnvelope => {
131
+ const behaviorIds = new Set(agents.flatMap((a) => a.behaviors.map((b) => String(b['id']))));
132
+ if (!behaviorIds.size) return env;
133
+
134
+ const relations = env.payload['relations'] as Record<string, Row[]>;
135
+ const isRemovedAutomation = (id: unknown) => behaviorIds.has(String(id));
136
+
137
+ const automations = (relations['automations'] ?? []).filter((a) => !isRemovedAutomation(a['id']));
138
+ const removedConditionIds = new Set(
139
+ (relations['conditions'] ?? [])
140
+ .filter((c) => c['source_type'] === 'Automation' && isRemovedAutomation(c['source_id']))
141
+ .map((c) => String(c['id'])),
142
+ );
143
+ const conditions = (relations['conditions'] ?? []).filter(
144
+ (c) => !(c['source_type'] === 'Automation' && isRemovedAutomation(c['source_id'])),
145
+ );
146
+ const conditionExpressions = (relations['condition_expressions'] ?? []).filter(
147
+ (e) => !removedConditionIds.has(String(e['condition_id'])),
148
+ );
149
+ const fieldMaps = (relations['field_maps'] ?? []).filter(
150
+ (m) => !(m['owner_type'] === 'Automation' && isRemovedAutomation(m['owner_id'])),
151
+ );
152
+
153
+ for (const agent of agents) {
154
+ for (const raw of agent.behaviors) {
155
+ const { condition, field_maps: behaviorFieldMaps, ...autoRow } = raw as Row & {
156
+ condition?: ConditionNode | null;
157
+ field_maps?: Row[];
158
+ };
159
+ const id = Number(autoRow['id']);
160
+ automations.push({ ...autoRow, id });
161
+ if (condition) unfoldCondition(condition, 'Automation', id, conditions, conditionExpressions);
162
+ for (const m of behaviorFieldMaps ?? []) fieldMaps.push({ ...m, owner_type: 'Automation', owner_id: id });
163
+ }
164
+ }
165
+
166
+ return {
167
+ ...env,
168
+ payload: {
169
+ ...env.payload,
170
+ relations: {
171
+ ...relations,
172
+ automations,
173
+ conditions,
174
+ condition_expressions: conditionExpressions,
175
+ field_maps: fieldMaps,
176
+ } as unknown as PayloadEnvelope['payload']['relations'],
177
+ },
178
+ };
179
+ };
180
+
181
+ /**
182
+ * The same map, rebuilt by reading whichever agent directories already exist
183
+ * on disk, rather than from a fresh fetch.
184
+ *
185
+ * Needed wherever a tree gets read back from the *live* pipe rather than from
186
+ * this workspace — `pipe apply`'s and `pipe verify --update`'s "adopt what the
187
+ * server has" step, both of which `unpack()` a fresh envelope with no agent
188
+ * folders in sight to derive `_agent_dir` from. Without this, adopting a
189
+ * verified apply would write every behavior straight into `automations/`,
190
+ * undoing the split on every single successful apply.
191
+ */
192
+ export const behaviorDirMapOnDisk = async (workspace: string): Promise<Map<string, string>> => {
193
+ const root = agentsRoot(workspace);
194
+ const dirById = new Map<string, string>();
195
+ if (!(await exists(root))) return dirById;
196
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
197
+ if (!entry.isDirectory()) continue;
198
+ const behaviorsDir = join(root, entry.name, 'behaviors');
199
+ if (!(await exists(behaviorsDir))) continue;
200
+ for (const file of await fs.readdir(behaviorsDir)) {
201
+ if (!file.endsWith('.json')) continue;
202
+ const row = await readJson<Row>(join(behaviorsDir, file));
203
+ if (row['id'] !== undefined && row['id'] !== null) dirById.set(String(row['id']), entry.name);
204
+ }
205
+ }
206
+ return dirById;
207
+ };
208
+
209
+ /**
210
+ * Tag each automation that is some agent's behavior with the directory it
211
+ * belongs under, by id. `workspace/write.ts` reads the tag back off to route
212
+ * the file; `workspace/read.ts` sets the same tag itself, from wherever it
213
+ * actually found the file, so this is only needed for a tree that did not come
214
+ * from `readTree` — a fresh pull, or a live read-back adopted after an apply.
215
+ */
216
+ export const tagBehaviors = <T extends Row>(automations: T[], dirById: Map<string, string>): T[] => {
217
+ if (!dirById.size) return automations;
218
+ return automations.map((a) => {
219
+ const dir = dirById.get(String(a['id']));
220
+ return dir ? ({ ...a, [AGENT_DIR_TAG]: dir } as T) : a;
221
+ });
222
+ };
223
+
224
+ // ── write ───────────────────────────────────────────────────────────────────
225
+
226
+ export const writeAgents = async (workspace: string, section: AgentsSection): Promise<string[]> => {
227
+ const root = agentsRoot(workspace);
228
+ /**
229
+ * Rewritten wholesale, same reasoning as `flows/`: an agent deleted upstream
230
+ * must disappear from the workspace too. Behaviors are not touched here —
231
+ * `writeTree` owns `behaviors/`, and runs after this, so an agent's folder
232
+ * exists (even empty) by the time it needs somewhere to put them.
233
+ */
234
+ if (await exists(root)) await fs.rm(root, { recursive: true, force: true });
235
+ await mkdirp(root);
236
+
237
+ const written: string[] = [];
238
+ const put = async (path: string, value: unknown) => {
239
+ await writeJson(path, value);
240
+ written.push(path);
241
+ };
242
+
243
+ await put(join(root, '_meta.json'), section.meta);
244
+ for (const agent of section.agents) {
245
+ const dir = join(root, agentDirName(agent));
246
+ await mkdirp(join(dir, 'behaviors'));
247
+ const { behaviors: _behaviors, ...persisted } = agent;
248
+ await put(join(dir, 'agent.json'), persisted);
249
+ }
250
+ return written;
251
+ };
252
+
253
+ // ── read ────────────────────────────────────────────────────────────────────
254
+
255
+ export const readAgents = async (workspace: string): Promise<AgentsSection | null> => {
256
+ const root = agentsRoot(workspace);
257
+ if (!(await exists(root))) return null;
258
+
259
+ const meta = (await readJson<AgentsMeta>(join(root, '_meta.json')).catch(() => null)) ?? {
260
+ coverage: 'unknown' as const,
261
+ readAt: new Date().toISOString(),
262
+ notes: ['agents/_meta.json is missing, so coverage is unknown'],
263
+ };
264
+
265
+ const agents: AgentRow[] = [];
266
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
267
+ if (!entry.isDirectory()) continue;
268
+ const agentJson = join(root, entry.name, 'agent.json');
269
+ if (!(await exists(agentJson))) continue;
270
+ const persisted = await readJson<Row>(agentJson);
271
+ agents.push({ ...persisted, behaviors: [] } as unknown as AgentRow);
272
+ }
273
+
274
+ return { meta, agents };
275
+ };
276
+
277
+ /** Record what was just read as the baseline, so the next diff needs no network. */
278
+ export const writeAgentBaseline = (workspaceRoot: string, repoId: number, section: AgentsSection) =>
279
+ writeJson(agentBaselineFile(workspaceRoot, repoId), section);
280
+
281
+ /** The recorded agent baseline, or null when this repo has never been pulled. */
282
+ export const readAgentBaseline = async (workspaceRoot: string, repoId: number): Promise<AgentsSection | null> => {
283
+ try {
284
+ return await readJson<AgentsSection>(agentBaselineFile(workspaceRoot, repoId));
285
+ } catch {
286
+ return null;
287
+ }
288
+ };
289
+
290
+ export const isNewAgent = (agent: AgentRow): boolean => !agent['uuid'] || agent['_new'] === true;