@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,407 @@
1
+ import { join } from 'node:path';
2
+ import { rm } from 'node:fs/promises';
3
+ import { writeFile, exists } from '../util/fsx.ts';
4
+ import type { Tree } from '../model/tree.ts';
5
+ import type { Capabilities } from '../pipefy/capability.ts';
6
+ import { registryRows } from '../apply/registry.ts';
7
+ import type { Lock } from './lock.ts';
8
+ import { paths } from './layout.ts';
9
+ import { renderReferenceMd, type Reference } from '../pipefy/reference.ts';
10
+
11
+ /**
12
+ * The generated docs that are the whole interface for whoever edits this
13
+ * workspace — PLAN.md §6. No agent baked into the app: the FDE pulls, edits
14
+ * with an agent in a terminal, and publishes. These files are what make that
15
+ * work unattended, regardless of which agent is reading them.
16
+ */
17
+
18
+ const AGENTS_MD = (lock: Lock, trees: Tree[]) => {
19
+ const reconstructed = lock.repos.filter((r) => r.source === 'reconstructed');
20
+ const unknownGroups = new Set<string>();
21
+ for (const r of lock.repos) {
22
+ for (const [name, cov] of Object.entries(r.coverage)) if (cov === 'unknown') unknownGroups.add(name);
23
+ }
24
+
25
+ return `# Editing this Pipefy workspace
26
+
27
+ This directory is a Pipefy pipe (and everything it connects to) pulled to disk as
28
+ JSON. Edit the JSON, then let \`pipe\` validate, plan and apply it. The rules below
29
+ are not style preferences — breaking one either corrupts a live pipe or makes the
30
+ apply refuse.
31
+
32
+ ## The loop
33
+
34
+ \`\`\`bash
35
+ pipe validate # JSON shape + referential integrity. Instant, offline.
36
+ pipe diff # what you changed, against the last pull
37
+ pipe plan --dry-run # is every change actually applicable? refuses if not
38
+ pipe apply # snapshot, apply, verify
39
+ \`\`\`
40
+
41
+ **\`pipe validate && pipe plan --dry-run\` must both be clean before you propose an
42
+ apply.** Iterate against them yourself; they need no network for validate and no
43
+ writes for plan.
44
+
45
+ ## Identity rules
46
+
47
+ - \`id\` is **server-owned**. Never invent one, never edit one, never reuse one.
48
+ - A **new** entity is \`"id": null\` plus \`"_new": true\`. For \`phases\`, \`fields\`,
49
+ \`automations\` and \`labels\` — the only four arrays that have one — also add a
50
+ fresh v4 \`uuid\`. Everything else is identified by its owner.
51
+ - To **delete**, remove the object (or the whole file). Do not set a tombstone.
52
+ - \`_\`-prefixed keys are tool metadata. Leave them alone; they are ignored by
53
+ every comparator and never sent to the API.
54
+
55
+ ## What cannot be changed at all
56
+
57
+ | Edit | Why it is refused |
58
+ | --- | --- |
59
+ | a field's \`type_id\` | \`updatePhaseField\` has no \`type_id\`. Doing it as delete+create destroys every existing value. |
60
+ | a field's \`phase_id\` | \`updatePhaseField\` has no \`phase_id\`. Same data loss. |
61
+ | a phase's \`index\` (reordering) | \`createPhase\` takes an index, \`updatePhase\` does not. |
62
+ | anything under \`_readonly/\` | no public write path exists. Read it, reason with it, don't edit it. |
63
+ | anything under \`_non-snapshot/\` | not part of the structure payload; never written back. |
64
+ | the start-form phase's existence, \`index\`, or \`name\` | it is a real phase at index 0; deleting or reindexing it is catastrophic, and its name is referenced by other things (the public form, automations, a client's own docs) this tool cannot check for breakage. |
65
+
66
+ If a request needs one of these, say so plainly and stop — do not work around it
67
+ with a delete and a create.
68
+
69
+ ## Field references
70
+
71
+ Automations and conditions refer to fields by numeric id inside opaque JSON:
72
+ \`"recipients": "%{<fieldId>}"\`, \`"field_address": "433255493"\`,
73
+ \`"trigger_field_ids": ["433255544"]\`. \`MAPPING.md\` translates every one of those
74
+ numbers into a label and a phase.
75
+
76
+ - **Deleting a field obliges you to fix every reference to it.** \`pipe validate\`
77
+ fails on a dangling reference, by design.
78
+ - Referencing a field you are creating in the same edit is fine: leave the
79
+ placeholder \`%{_new:<uuid>}\` and the applier substitutes the real id after the
80
+ field exists.
81
+
82
+ ## Flows reference fields too, and \`pipe validate\` does not check them
83
+
84
+ If this workspace has any \`flows/\` directories, they are iPaaS flows (Advanced
85
+ Automations) — the same entities as the pipe, pulled, diffed, planned and
86
+ applied by the same \`pipe pull/diff/plan/apply\` commands (\`--no-flows\` opts
87
+ out). A flow's datapills address a field by **slug**, inside opaque strings:
88
+ \`{{step_1['output'].data.card.fields.<slug>.value}}\`.
89
+
90
+ - **\`pipe validate\` never looks inside a flow.** Renaming, retyping or deleting
91
+ a field is checked against automations and conditions, never against flow
92
+ datapills — a broken reference there is silent until the flow runs. Before
93
+ editing a field a flow might use, grep \`flows/\` for its slug yourself.
94
+ - A field's slug does **not** change when its label is renamed (measured;
95
+ \`VALIDATION.md\` §3g), so a label-only rename is safe for existing datapills.
96
+ Anything that changes or removes the slug is not.
97
+ - **Adding a node to an existing flow works if the node is reachable from the
98
+ trigger** — some other node's \`_nextAction\`, \`_firstLoopAction\` or a
99
+ branch's \`_children\` must point at it, which is how the diff finds the
100
+ parent to send \`ADD_ACTION\` after. An orphan node nothing points to is
101
+ still refused, by name, rather than guessed at.
102
+ - Enabling a flow arms it against live traffic and is refused unless you pass
103
+ \`--allow-flow-enable\`, the same shape as \`--allow-card-moves\`.
104
+ - Load the \`ppc-pipefy-flow-authoring\` skill before writing or debugging a flow by
105
+ hand — it covers node shapes, datapill syntax and the traps that validate
106
+ clean and resolve nothing at runtime.
107
+
108
+ ## Values that come from a closed set
109
+
110
+ \`REFERENCE.md\` in this workspace lists what the API accepts in every position
111
+ where a value must come from a fixed list: each automation event's
112
+ \`event_params\`, each action's \`action_params\`, which events and actions cannot be
113
+ paired, and the field \`type_id\`, \`color\` and \`filter\` sets. It was read from the
114
+ API on the last pull.
115
+
116
+ **Read it before writing an automation.** The schema publishes these lists and
117
+ then types the inputs that consume them as \`ID\` and \`String\`, so a wrong value is
118
+ not caught by GraphQL. It reaches a runtime check and comes back as \`is invalid\`
119
+ or \`All fields must be filled properly.\` — naming no parameter, no event, and no
120
+ accepted value, partway through an apply.
121
+
122
+ The traps are near-misses, not nonsense. \`in_phase_id\` is a real parameter that
123
+ belongs to \`card_inbox_received_email\`; \`card_moved\` takes only \`to_phase_id\`.
124
+ \`card_id\` is real on \`update_card_field\` and refused on \`move_single_card\`.
125
+
126
+ \`pipe validate\` checks all of this offline and names the parameter, so a wrong
127
+ value costs a second rather than a half-applied change. It is a backstop, not a
128
+ substitute for reading the file.
129
+
130
+ ## Where things live
131
+
132
+ \`\`\`
133
+ pipes/<id>--<name>/
134
+ pipe.json the repo row
135
+ phases/NN--name.json phase + its fields[] + jump_target_ids[] + team_member_ids[]
136
+ automations/<name>.json automation + condition + field_maps + response_schemas
137
+ field-conditions/<name>.json condition + actions
138
+ labels.json webhooks.json public-form.json preferences.json relations.json
139
+ _readonly/ _non-snapshot/ _meta.json
140
+ databases/<id>--<apiId>--<name>/
141
+ table.json the database row
142
+ phases/00--start-form.json the database's COLUMNS live here, as fields[]
143
+ phases/NN--*.json record statuses — readable, no write path
144
+ README.md what each of those files actually is
145
+ \`\`\`
146
+
147
+ Fields live **inside their phase file** — that is where they make sense and where
148
+ you will edit them most. Ordering comes from the \`index\` value inside the file,
149
+ never from the filename, so renumbering works without renaming anything.
150
+
151
+ ${
152
+ reconstructed.length
153
+ ? `## This workspace was read without snapshots
154
+
155
+ ${reconstructed.length} of ${lock.repos.length} repo(s) were reconstructed from the
156
+ GraphQL and internal APIs rather than from a snapshot payload. Consequences you
157
+ must respect:
158
+
159
+ ${[...unknownGroups].sort().map((g) => `- \`${g}\` is **unknown**, not empty. Do not add to it and do not conclude the pipe has none.`).join('\n')}
160
+
161
+ An \`unknown\` entity group is never diffed and never applied. \`_meta.json\` in each
162
+ repo directory records exactly what was and was not read.
163
+ `
164
+ : `## This workspace was read from snapshots
165
+
166
+ Every entity group came from the snapshot payload, so absence means absence.
167
+ `
168
+ }
169
+ ## Secrets
170
+
171
+ Webhook URLs are bearer-equivalent — possession is authorization — and
172
+ HTTP-request automations can carry live credentials in \`action_params\`. This
173
+ workspace has a \`.gitignore\`, but do not paste these files into anything, and do
174
+ not commit the workspace.
175
+
176
+ ## Runs
177
+
178
+ Pass \`--report\` to any of pull, apply, dry run or verify and it writes a folder
179
+ under \`runs/\` recording what happened:
180
+
181
+ \`\`\`
182
+ runs/index.md one line per run, oldest first
183
+ runs/<when>--<kind>/report.md what happened, readable, no credentials
184
+ runs/<when>--<kind>/run.json the same thing structured
185
+ runs/<when>--<kind>/pipe.json the payload as of the end of that run
186
+ runs/<when>--<kind>/integrations.json webhooks, relations, cross-repo automations
187
+ \`\`\`
188
+
189
+ An apply also writes \`pipe.before.json\`, so both sides of the change sit in one
190
+ folder. Read \`report.md\` first — it is the only file in there written for a
191
+ human.
192
+
193
+ It is off by default: a run folder holds a full copy of the pipe, credentials
194
+ included, and one per command would grow without bound. The record an apply
195
+ needs in order to resume is the plan file under \`.ppc/plans/\`, which is always
196
+ written.
197
+
198
+ ${trees.length ? `## This workspace\n\n${trees.map((t) => `- ${t.repo['name']} (${t.meta.repoKind} ${t.repo['id']}) — ${t.phases.length} phases, ${t.phases.reduce((n, p) => n + p.fields.length, 0)} fields, ${t.automations.length} automations, read from ${t.meta.source}`).join('\n')}` : ''}
199
+ `;
200
+ };
201
+
202
+ const MAPPING_MD = (trees: Tree[], lock: Lock) => {
203
+ const sections = trees.map((t) => {
204
+ const fields = t.phases.flatMap((p) => p.fields.map((f) => ({ f, phase: String(p['name'] ?? '') })));
205
+ const fieldRows = fields
206
+ .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | \`${f['slug'] ?? ''}\` | ${f['type_id']} | ${phase} |`)
207
+ .join('\n');
208
+
209
+ const phaseRows = t.phases
210
+ .map(
211
+ (p) =>
212
+ `| ${p['index']} | \`${p['id']}\` | ${p['name']}${Number(p['index']) === 0 ? ' **(start form)**' : ''} | ${
213
+ p.fields.length
214
+ } | ${p.jump_target_ids.length ? p.jump_target_ids.map((j) => `\`${j}\``).join(', ') : '—'} |`,
215
+ )
216
+ .join('\n');
217
+
218
+ const autoRows = t.automations
219
+ .map(
220
+ (a) =>
221
+ `| \`${a['id']}\` | ${a['name']} | ${a['event_id']} → ${a['action_id']} | ${
222
+ a['action_repo_id'] !== a['event_repo_id'] ? `cross-repo → \`${a['action_repo_id']}\`` : 'same repo'
223
+ } | ${a.condition ? 'yes' : '—'} |`,
224
+ )
225
+ .join('\n');
226
+
227
+ const connectors = fields
228
+ .filter(({ f }) => f['type_id'] === 'connector')
229
+ .map(({ f, phase }) => `| \`${f['id']}\` | ${f['label']} | ${phase} | \`${f['connected_pipe_id']}\` |`)
230
+ .join('\n');
231
+
232
+ return `## ${t.repo['name']} — ${t.meta.repoKind} \`${t.repo['id']}\`
233
+
234
+ ### Phases
235
+
236
+ | index | id | name | fields | jumps to |
237
+ | --- | --- | --- | --- | --- |
238
+ ${phaseRows}
239
+
240
+ ### Fields
241
+
242
+ | id | label | slug | type | phase |
243
+ | --- | --- | --- | --- | --- |
244
+ ${fieldRows}
245
+
246
+ ### Automations
247
+
248
+ | id | name | event → action | scope | condition |
249
+ | --- | --- | --- | --- | --- |
250
+ ${autoRows || '| — | none | | | |'}
251
+
252
+ ${connectors ? `### Connector fields\n\n| field id | label | phase | connected repo |\n| --- | --- | --- | --- |\n${connectors}\n` : ''}`;
253
+ });
254
+
255
+ /**
256
+ * Edges name what they point at, not just its id.
257
+ *
258
+ * `301908697` on its own tells a reader nothing — and a connector field aimed
259
+ * at a database means something different from one aimed at a pipe: the target
260
+ * has columns and records rather than phases and cards. The repo is looked up
261
+ * in the lock, so a target outside the workspace still shows as unknown rather
262
+ * than being guessed at.
263
+ */
264
+ const byId = new Map(lock.repos.map((r) => [String(r.id), r]));
265
+ const edgeRows = lock.edges
266
+ .map((e) => {
267
+ const to = byId.get(String(e.to));
268
+ const target = to ? `${to.kind} · ${to.name}` : 'not in this workspace';
269
+ return `| \`${e.from}\` | \`${e.to}\` | ${target} | ${e.kind} | ${e.label ?? ''} |`;
270
+ })
271
+ .join('\n');
272
+
273
+ return `# Mapping
274
+
275
+ Generated on pull. This is what makes \`%{<fieldId>}\` mean "Requester email".
276
+
277
+ ${sections.join('\n\n')}
278
+
279
+ ## Connections
280
+
281
+ Discovered as the union of \`pipe_relations\`, connector fields, and cross-repo
282
+ automations — connector fields alone find edges that neither \`pipe_relations\` nor
283
+ \`pipeMapGraph\` reports (PLAN.md §2.2).
284
+
285
+ | from | to | what it is | via | label |
286
+ | --- | --- | --- | --- | --- |
287
+ ${edgeRows || '| — | — | — | — | — |'}
288
+ `;
289
+ };
290
+
291
+ const CAPABILITIES_MD = (caps: Capabilities | null) => {
292
+ const rows = registryRows()
293
+ .sort((a, b) => a.key.localeCompare(b.key))
294
+ .map(
295
+ (r) =>
296
+ `| \`${r.entity}\` | ${r.op} | ${
297
+ r.status === 'SUPPORTED' ? 'yes' : r.status === 'PARTIAL' ? 'partly' : '**no**'
298
+ } | ${r.via ? `\`${r.via}\`` : '—'} | ${r.reason ?? ''}${r.ask ? ` _(ask ${r.ask})_` : ''} |`,
299
+ )
300
+ .join('\n');
301
+
302
+ const detected = caps
303
+ ? `## Detected on this endpoint
304
+
305
+ | Capability | Present | Consequence if absent |
306
+ | --- | --- | --- |
307
+ | snapshots (\`createRepoSnapshot\`) | ${caps.snapshots ? 'yes' : '**no**'} | pulls reconstruct the structure over GraphQL instead |
308
+ | \`restoreRepoSnapshot\` | ${caps.restoreRepoSnapshot ? 'yes' : '**no**'} | no atomic rollback; a failed apply is repaired forward |
309
+ | \`createRepoDraft\` | ${caps.createRepoDraft ? 'yes' : '**no**'} | no free rehearsal environment; plans are checked, not proven |
310
+ | \`importRepoSnapshot\` | ${caps.importRepoSnapshot ? 'yes' : 'no'} | Route B is unavailable; writes go through the mutation compiler |
311
+ | internal API | ${caps.internalApi} | automation detail and phase-jump editing depend on it |
312
+
313
+ ${caps.internalApi === 'unavailable' ? '> The internal endpoint did not answer. Automations and phase jumps are reported as **unknown** rather than empty, and any change to them is refused rather than silently skipped.\n' : ''}`
314
+ : '';
315
+
316
+ return `# Capabilities
317
+
318
+ Generated from the live registry in \`src/apply/registry.ts\`, so what you read
319
+ here is what the applier will actually attempt. If something says **no**, propose
320
+ a different change rather than a workaround.
321
+
322
+ ${detected}
323
+ ## Write coverage
324
+
325
+ | Entity | Operation | Writable | Via | Notes |
326
+ | --- | --- | --- | --- | --- |
327
+ ${rows}
328
+
329
+ ## Flows (iPaaS), separately from the table above
330
+
331
+ Flows compile through \`src/apply/flowops.ts\`, not the registry above — they are
332
+ still applied by the same \`pipe apply\` (skip with \`--no-flows\`), just tracked
333
+ here rather than in the write-coverage table.
334
+
335
+ | Change | Writable | Via | Notes |
336
+ | --- | --- | --- | --- |
337
+ | create a flow | yes | \`createFlow\` (whole tree) | built trigger-first, then each node in execution order |
338
+ | delete a flow | yes | \`deleteFlow\` | destructive |
339
+ | rename a flow | yes | \`CHANGE_NAME\` | |
340
+ | update the trigger | yes | \`UPDATE_TRIGGER\` | whole node, not a sparse patch |
341
+ | update an action | yes | \`UPDATE_ACTION\` | whole node; a router's branches live in \`settings.branches\`, so editing them is an \`UPDATE_ACTION\` on the router — there is no \`UPDATE_BRANCH\` |
342
+ | delete an action | yes | \`DELETE_ACTION\` | destructive |
343
+ | add a node **to an existing flow** | yes, if reachable from the trigger | \`ADD_ACTION\` | the parent is found by scanning the authored tree for whoever's \`_nextAction\` / \`_firstLoopAction\` / \`_children\` points at the new node. An orphan node nothing points to is refused by name |
344
+ | enable a flow | partly | \`CHANGE_STATUS\` | arms it against live traffic — refused unless \`--allow-flow-enable\`, and refused outright while any connection it uses is still a placeholder |
345
+ | disable a flow | yes | \`CHANGE_STATUS\` | |
346
+ | a connection the flow needs but the project lacks | yes, as a placeholder | \`createConnection\` | created disabled with a placeholder credential so the structure can still be built; reconnect it for real before enabling |
347
+ | whole-tree replace | **refused on purpose** | — | \`IMPORT_FLOW\` exists and is never used — it re-runs an old schema migration on an already-live tree and doubles every datapill (\`{{step_1['output'].x}}\` → \`{{step_1['output']['output'].x}}\`), resolving nothing at runtime while every validator still calls it valid |
348
+
349
+ After any flow write, the applier reads the flow back and diffs its datapills
350
+ against what was sent — the only check that catches the \`IMPORT_FLOW\`-shaped
351
+ migration bug, and the reason a flow apply is not reported successful on the
352
+ mutation's response alone.
353
+
354
+ ## The three that hurt most
355
+
356
+ 1. **Field type changes** — routine to ask for, impossible to do without
357
+ destroying data. Refused.
358
+ 2. **Moving a field between phases** — same.
359
+ 3. **Reordering phases** — \`createPhase\` takes an index, \`updatePhase\` does not.
360
+
361
+ These are asks 2, 3 and 4 in \`ROUTE-B-V2.md\`. Every refusal pipe emits is a data
362
+ point for them.
363
+ `;
364
+ };
365
+
366
+ const GITIGNORE = `# A pulled workspace contains webhook URLs (bearer-equivalent: possession is
367
+ # authorization) and HTTP-request automation credentials. Never commit it.
368
+ .ppc/
369
+ *.gz
370
+
371
+ # Run folders hold a full copy of the pipe (runs/*/pipe.json) and its integration
372
+ # surface, credentials included. The report beside them is redacted and can be
373
+ # shared by hand; the payloads cannot.
374
+ runs/*/pipe*.json
375
+ runs/*/integrations*.json
376
+
377
+ # Uncomment to keep the structure out of git entirely.
378
+ # pipes/
379
+ # databases/
380
+ # runs/
381
+ `;
382
+
383
+ export const writeDocs = async (
384
+ root: string,
385
+ lock: Lock,
386
+ trees: Tree[],
387
+ caps: Capabilities | null,
388
+ ref: Reference | null = null,
389
+ ) => {
390
+ const p = paths(root);
391
+ await writeFile(p.agentsMd, AGENTS_MD(lock, trees));
392
+ await writeFile(p.mappingMd, MAPPING_MD(trees, lock));
393
+ await writeFile(p.capabilitiesMd, CAPABILITIES_MD(caps));
394
+ await writeFile(p.referenceMd, renderReferenceMd(ref));
395
+ await writeFile(p.gitignore, GITIGNORE);
396
+
397
+ /**
398
+ * Migration cleanup: a workspace pulled before this rename has a stale
399
+ * `CLAUDE.md` sitting beside the new `AGENTS.md`, still telling whoever
400
+ * reads it to look here — remove it rather than leave a duplicate that
401
+ * silently goes out of date the next time this file is regenerated.
402
+ */
403
+ const staleClaudeMd = join(root, 'CLAUDE.md');
404
+ if (await exists(staleClaudeMd)) await rm(staleClaudeMd, { force: true });
405
+
406
+ return [p.agentsMd, p.mappingMd, p.capabilitiesMd, p.referenceMd, p.gitignore];
407
+ };
@@ -0,0 +1,191 @@
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 { flowBaselineFile } from './layout.ts';
5
+ import { slug } from '../util/slug.ts';
6
+ import { canonicalString } from '../util/json.ts';
7
+ import { unlinkFlow, linkFlow, type FlowTree } from '../codec/flow.ts';
8
+ import type { Row } from '../model/payload.ts';
9
+ import type { ConnectionRow, RunRow } from '../pipefy/ipaas.ts';
10
+
11
+ /**
12
+ * The `flows/` half of a workspace.
13
+ *
14
+ * A pipe's iPaaS flows are entities of that pipe — the pipe's own webhook URLs
15
+ * carry their ids — so they live beside `pipes/`, read by the same pull and
16
+ * written by the same apply.
17
+ *
18
+ * pipes/<id>--<name>/flows/
19
+ * _meta.json coverage, projectId, counts, read time
20
+ * connections.json externalId, pieceName, displayName, status
21
+ * pieces.json pieceName -> version the instance offers
22
+ * _readonly/flow-runs.json recent outcomes; never written back
23
+ * <flowId>--<slug>/
24
+ * flow.json the flow row
25
+ * version.json the version row, without the trigger
26
+ * nodes/NN--<name>.json one node per file, in execution order
27
+ *
28
+ * Coverage matters here as much as anywhere: a pipe whose iPaaS could not be
29
+ * reached gets `unknown`, which is never read as "this pipe has no flows".
30
+ */
31
+
32
+ export const FLOWS_DIR = 'flows';
33
+
34
+ export type FlowsMeta = {
35
+ coverage: 'complete' | 'unknown';
36
+ projectId?: string;
37
+ readAt: string;
38
+ flows?: number;
39
+ connections?: number;
40
+ pieces?: number;
41
+ reason?: string;
42
+ notes: string[];
43
+ };
44
+
45
+ export type FlowsSection = {
46
+ meta: FlowsMeta;
47
+ /** flowId → the flow, in the ordered form. A new flow has `_new` and no id. */
48
+ flows: FlowTree[];
49
+ connections: ConnectionRow[];
50
+ pieces: Record<string, string>;
51
+ runs: RunRow[];
52
+ };
53
+
54
+ /**
55
+ * The flows directory of ONE repo. Under the repo, not at the workspace root:
56
+ * a workspace can hold several pipes, each with its own iPaaS workspace, and a
57
+ * shared directory meant the second pipe's pull deleted the first pipe's flows.
58
+ */
59
+ export const flowsRoot = (repoDir: string) => join(repoDir, FLOWS_DIR);
60
+
61
+ export const flowDirName = (tree: FlowTree) => {
62
+ const id = tree.flow['id'] ? String(tree.flow['id']) : '_new';
63
+ return `${id}--${slug(String(tree.version['displayName'] ?? 'flow'))}`;
64
+ };
65
+
66
+ /** The empty section, for a pipe with no iPaaS or one that could not be read. */
67
+ export const unknownFlows = (reason: string): FlowsSection => ({
68
+ meta: { coverage: 'unknown', readAt: new Date().toISOString(), reason, notes: [reason] },
69
+ flows: [],
70
+ connections: [],
71
+ pieces: {},
72
+ runs: [],
73
+ });
74
+
75
+ // ── write ───────────────────────────────────────────────────────────────────
76
+
77
+ export const writeFlows = async (workspace: string, section: FlowsSection): Promise<string[]> => {
78
+ const root = flowsRoot(workspace);
79
+ /**
80
+ * Rewritten wholesale rather than merged: a flow deleted upstream has to
81
+ * disappear from the workspace too, and a leftover directory would be read as a
82
+ * flow the author wants created.
83
+ */
84
+ if (await exists(root)) await fs.rm(root, { recursive: true, force: true });
85
+ await mkdirp(join(root, '_readonly'));
86
+
87
+ const written: string[] = [];
88
+ const put = async (path: string, value: unknown) => {
89
+ await writeJson(path, value);
90
+ written.push(path);
91
+ };
92
+
93
+ await put(join(root, '_meta.json'), section.meta);
94
+ await put(join(root, 'connections.json'), section.connections);
95
+ await put(join(root, 'pieces.json'), section.pieces);
96
+ await put(join(root, '_readonly', 'flow-runs.json'), section.runs);
97
+
98
+ for (const tree of section.flows) {
99
+ const dir = join(root, flowDirName(tree));
100
+ await mkdirp(join(dir, 'nodes'));
101
+ await put(join(dir, 'flow.json'), tree.flow);
102
+ await put(join(dir, 'version.json'), tree.version);
103
+ for (const [i, node] of tree.nodes.entries()) {
104
+ await put(join(dir, 'nodes', `${String(i).padStart(2, '0')}--${node.name}.json`), node);
105
+ }
106
+ }
107
+ return written;
108
+ };
109
+
110
+ // ── read ────────────────────────────────────────────────────────────────────
111
+
112
+ export const readFlows = async (workspace: string): Promise<FlowsSection | null> => {
113
+ const root = flowsRoot(workspace);
114
+ if (!(await exists(root))) return null;
115
+
116
+ const meta = (await readJson<FlowsMeta>(join(root, '_meta.json')).catch(() => null)) ?? {
117
+ coverage: 'unknown' as const,
118
+ readAt: new Date().toISOString(),
119
+ notes: ['flows/_meta.json is missing, so coverage is unknown'],
120
+ };
121
+ const connections = (await readJson<ConnectionRow[]>(join(root, 'connections.json')).catch(() => [])) ?? [];
122
+ const pieces = (await readJson<Record<string, string>>(join(root, 'pieces.json')).catch(() => ({}))) ?? {};
123
+ const runs =
124
+ (await readJson<RunRow[]>(join(root, '_readonly', 'flow-runs.json')).catch(() => [])) ?? [];
125
+
126
+ const flows: FlowTree[] = [];
127
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
128
+ if (!entry.isDirectory()) continue;
129
+ /**
130
+ * Only the tool's own metadata directories are skipped, not everything with a
131
+ * leading underscore.
132
+ *
133
+ * `flowDirName` names a flow that does not exist yet `_new--<slug>`, so a
134
+ * blanket `startsWith('_')` filter made an authored flow invisible to the
135
+ * reader that produced it — the diff reported "no changes" over a whole flow
136
+ * waiting to be created.
137
+ */
138
+ if (entry.name === '_readonly') continue;
139
+ const dir = join(root, entry.name);
140
+ const flow = await readJson<Row>(join(dir, 'flow.json'));
141
+ const version = await readJson<Row>(join(dir, 'version.json'));
142
+
143
+ const nodeDir = join(dir, 'nodes');
144
+ const files = (await fs.readdir(nodeDir)).filter((f) => f.endsWith('.json')).sort();
145
+ const nodes = [];
146
+ for (const f of files) {
147
+ const node = await readJson<Row>(join(nodeDir, f));
148
+ nodes.push({ ...node, name: String(node['name'] ?? ''), type: String(node['type'] ?? '') });
149
+ }
150
+ /**
151
+ * Order comes from the filename prefix, which is how the codec wrote it — but
152
+ * the trigger must be first for `linkFlow` to find the head of the chain, and
153
+ * a rename could put something else there.
154
+ */
155
+ const triggerAt = nodes.findIndex((n) => n.name === 'trigger');
156
+ if (triggerAt > 0) nodes.unshift(...nodes.splice(triggerAt, 1));
157
+ flows.push({ flow, version, nodes });
158
+ }
159
+
160
+ return { meta, flows, connections, pieces, runs };
161
+ };
162
+
163
+ /**
164
+ * Record what was just read as the baseline, so the next diff has a past to
165
+ * compare against without touching the network.
166
+ */
167
+ export const writeFlowBaseline = (workspaceRoot: string, repoId: number, section: FlowsSection) =>
168
+ writeJson(flowBaselineFile(workspaceRoot, repoId), section);
169
+
170
+ /** The recorded flow baseline, or null when this repo has never been pulled. */
171
+ export const readFlowBaseline = async (workspaceRoot: string, repoId: number): Promise<FlowsSection | null> => {
172
+ try {
173
+ return await readJson<FlowsSection>(flowBaselineFile(workspaceRoot, repoId));
174
+ } catch {
175
+ return null;
176
+ }
177
+ };
178
+
179
+ /** The flow row as the API would return it, for comparison and for operations. */
180
+ export const asApiFlow = (tree: FlowTree): Row => linkFlow(tree);
181
+
182
+ /** Whether two flow trees are the same, ignoring `_` metadata differences in order. */
183
+ export const sameFlow = (a: FlowTree, b: FlowTree) => canonicalString(linkFlow(a)) === canonicalString(linkFlow(b));
184
+
185
+ export const flowIdOf = (tree: FlowTree): string | null =>
186
+ tree.flow['id'] ? String(tree.flow['id']) : null;
187
+
188
+ export const isNewFlow = (tree: FlowTree): boolean => !flowIdOf(tree) || tree.flow['_new'] === true;
189
+
190
+ /** Rebuild a tree from an API flow row, for the read path and for verification. */
191
+ export const treeFromApi = (row: Row): FlowTree => unlinkFlow(row);