@pipefy/pipefy-process-coder 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
- package/AGENTS.md +456 -0
- package/README.md +808 -0
- package/bin/pipe.js +13 -0
- package/package.json +35 -0
- package/src/apply/adopt.ts +150 -0
- package/src/apply/agentops.ts +71 -0
- package/src/apply/compile.ts +875 -0
- package/src/apply/execute.ts +336 -0
- package/src/apply/flowops.ts +399 -0
- package/src/apply/idmap.ts +241 -0
- package/src/apply/mutations.ts +955 -0
- package/src/apply/registry.ts +430 -0
- package/src/apply/types.ts +134 -0
- package/src/cli/args.ts +88 -0
- package/src/cli.ts +211 -0
- package/src/codec/flow.ts +199 -0
- package/src/codec/pack.ts +103 -0
- package/src/codec/roundtrip.ts +94 -0
- package/src/codec/unpack.ts +198 -0
- package/src/commands/agents.ts +184 -0
- package/src/commands/apply.ts +1144 -0
- package/src/commands/context.ts +119 -0
- package/src/commands/create.ts +79 -0
- package/src/commands/diff.ts +314 -0
- package/src/commands/flows.ts +414 -0
- package/src/commands/misc.ts +644 -0
- package/src/commands/plan.ts +331 -0
- package/src/commands/pull.ts +567 -0
- package/src/commands/runs.ts +83 -0
- package/src/commands/skills.ts +137 -0
- package/src/commands/verify.ts +253 -0
- package/src/config.ts +168 -0
- package/src/diff/agents.ts +122 -0
- package/src/diff/diff.ts +1130 -0
- package/src/diff/flow.ts +318 -0
- package/src/diff/html.ts +322 -0
- package/src/diff/render.ts +101 -0
- package/src/model/payload.ts +154 -0
- package/src/model/tree.ts +99 -0
- package/src/model/volatile.ts +55 -0
- package/src/pipefy/agents.ts +165 -0
- package/src/pipefy/automations.ts +219 -0
- package/src/pipefy/capability.ts +119 -0
- package/src/pipefy/client.ts +267 -0
- package/src/pipefy/discovery.ts +209 -0
- package/src/pipefy/internal.ts +380 -0
- package/src/pipefy/ipaas.ts +365 -0
- package/src/pipefy/reconstruct.ts +775 -0
- package/src/pipefy/reference.ts +251 -0
- package/src/pipefy/snapshot.ts +245 -0
- package/src/pipefy/toolkit.ts +200 -0
- package/src/pipefy/toolkit_bearer.py +137 -0
- package/src/report/integrations.ts +231 -0
- package/src/report/run.ts +475 -0
- package/src/util/fsx.ts +45 -0
- package/src/util/git.ts +32 -0
- package/src/util/json.ts +55 -0
- package/src/util/log.ts +76 -0
- package/src/util/pool.ts +48 -0
- package/src/util/slug.ts +26 -0
- package/src/util/tui.ts +335 -0
- package/src/validate/index.ts +123 -0
- package/src/validate/integrity.ts +387 -0
- package/src/validate/reference.ts +136 -0
- package/src/validate/schema.ts +328 -0
- package/src/workspace/agents.ts +290 -0
- package/src/workspace/docs.ts +407 -0
- package/src/workspace/flows.ts +191 -0
- package/src/workspace/layout.ts +165 -0
- package/src/workspace/lock.ts +148 -0
- package/src/workspace/read.ts +165 -0
- package/src/workspace/reference.ts +24 -0
- package/src/workspace/stamp.ts +301 -0
- package/src/workspace/write.ts +225 -0
|
@@ -0,0 +1,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);
|