@pipefy/pipefy-process-coder 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -262
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
  19. package/AGENTS.md +476 -456
  20. package/LICENSE +202 -0
  21. package/NOTICE +11 -0
  22. package/README.md +830 -808
  23. package/bin/pipe.js +25 -4
  24. package/dist/apply/adopt.js +128 -0
  25. package/dist/apply/agentops.js +42 -0
  26. package/dist/apply/compile.js +722 -0
  27. package/dist/apply/execute.js +277 -0
  28. package/dist/apply/flowops.js +293 -0
  29. package/dist/apply/idmap.js +233 -0
  30. package/dist/apply/mutations.js +704 -0
  31. package/dist/apply/registry.js +348 -0
  32. package/dist/apply/types.js +18 -0
  33. package/dist/cli/args.js +79 -0
  34. package/dist/cli.js +196 -0
  35. package/dist/codec/flow.js +164 -0
  36. package/dist/codec/pack.js +88 -0
  37. package/dist/codec/roundtrip.js +86 -0
  38. package/dist/codec/unpack.js +181 -0
  39. package/dist/commands/agents.js +145 -0
  40. package/dist/commands/apply.js +982 -0
  41. package/dist/commands/context.js +79 -0
  42. package/dist/commands/create.js +63 -0
  43. package/dist/commands/diff.js +294 -0
  44. package/dist/commands/flows.js +348 -0
  45. package/dist/commands/misc.js +562 -0
  46. package/dist/commands/plan.js +301 -0
  47. package/dist/commands/pull.js +549 -0
  48. package/dist/commands/runs.js +74 -0
  49. package/dist/commands/skills.js +131 -0
  50. package/dist/commands/verify.js +205 -0
  51. package/dist/config.js +125 -0
  52. package/dist/diff/agents.js +80 -0
  53. package/dist/diff/diff.js +983 -0
  54. package/dist/diff/flow.js +258 -0
  55. package/{src/diff/html.ts → dist/diff/html.js} +274 -322
  56. package/dist/diff/render.js +90 -0
  57. package/dist/model/payload.js +84 -0
  58. package/dist/model/tree.js +7 -0
  59. package/dist/model/volatile.js +54 -0
  60. package/dist/pipefy/agents.js +131 -0
  61. package/dist/pipefy/automations.js +155 -0
  62. package/dist/pipefy/capability.js +84 -0
  63. package/dist/pipefy/client.js +204 -0
  64. package/dist/pipefy/discovery.js +148 -0
  65. package/dist/pipefy/internal.js +406 -0
  66. package/dist/pipefy/ipaas.js +269 -0
  67. package/dist/pipefy/reconstruct.js +676 -0
  68. package/dist/pipefy/reference.js +172 -0
  69. package/dist/pipefy/snapshot.js +185 -0
  70. package/dist/pipefy/toolkit.js +174 -0
  71. package/{src → dist}/pipefy/toolkit_bearer.py +60 -14
  72. package/dist/report/integrations.js +165 -0
  73. package/dist/report/run.js +356 -0
  74. package/dist/util/fsx.js +50 -0
  75. package/dist/util/git.js +25 -0
  76. package/dist/util/json.js +56 -0
  77. package/dist/util/log.js +69 -0
  78. package/dist/util/pool.js +42 -0
  79. package/dist/util/slug.js +23 -0
  80. package/dist/util/tui.js +287 -0
  81. package/dist/validate/index.js +86 -0
  82. package/dist/validate/integrity.js +370 -0
  83. package/dist/validate/reference.js +120 -0
  84. package/dist/validate/schema.js +278 -0
  85. package/dist/version.js +18 -0
  86. package/dist/workspace/agents.js +245 -0
  87. package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
  88. package/dist/workspace/flows.js +149 -0
  89. package/dist/workspace/layout.js +146 -0
  90. package/dist/workspace/lock.js +83 -0
  91. package/dist/workspace/read.js +168 -0
  92. package/dist/workspace/reference.js +24 -0
  93. package/dist/workspace/stamp.js +234 -0
  94. package/dist/workspace/write.js +194 -0
  95. package/package.json +38 -35
  96. package/src/apply/adopt.ts +0 -150
  97. package/src/apply/agentops.ts +0 -71
  98. package/src/apply/compile.ts +0 -875
  99. package/src/apply/execute.ts +0 -336
  100. package/src/apply/flowops.ts +0 -399
  101. package/src/apply/idmap.ts +0 -241
  102. package/src/apply/mutations.ts +0 -955
  103. package/src/apply/registry.ts +0 -430
  104. package/src/apply/types.ts +0 -134
  105. package/src/cli/args.ts +0 -88
  106. package/src/cli.ts +0 -211
  107. package/src/codec/flow.ts +0 -199
  108. package/src/codec/pack.ts +0 -103
  109. package/src/codec/roundtrip.ts +0 -94
  110. package/src/codec/unpack.ts +0 -198
  111. package/src/commands/agents.ts +0 -184
  112. package/src/commands/apply.ts +0 -1144
  113. package/src/commands/context.ts +0 -119
  114. package/src/commands/create.ts +0 -79
  115. package/src/commands/diff.ts +0 -314
  116. package/src/commands/flows.ts +0 -414
  117. package/src/commands/misc.ts +0 -644
  118. package/src/commands/plan.ts +0 -331
  119. package/src/commands/pull.ts +0 -567
  120. package/src/commands/runs.ts +0 -83
  121. package/src/commands/skills.ts +0 -137
  122. package/src/commands/verify.ts +0 -253
  123. package/src/config.ts +0 -168
  124. package/src/diff/agents.ts +0 -122
  125. package/src/diff/diff.ts +0 -1130
  126. package/src/diff/flow.ts +0 -318
  127. package/src/diff/render.ts +0 -101
  128. package/src/model/payload.ts +0 -154
  129. package/src/model/tree.ts +0 -99
  130. package/src/model/volatile.ts +0 -55
  131. package/src/pipefy/agents.ts +0 -165
  132. package/src/pipefy/automations.ts +0 -219
  133. package/src/pipefy/capability.ts +0 -119
  134. package/src/pipefy/client.ts +0 -267
  135. package/src/pipefy/discovery.ts +0 -209
  136. package/src/pipefy/internal.ts +0 -380
  137. package/src/pipefy/ipaas.ts +0 -365
  138. package/src/pipefy/reconstruct.ts +0 -775
  139. package/src/pipefy/reference.ts +0 -251
  140. package/src/pipefy/snapshot.ts +0 -245
  141. package/src/pipefy/toolkit.ts +0 -200
  142. package/src/report/integrations.ts +0 -231
  143. package/src/report/run.ts +0 -475
  144. package/src/util/fsx.ts +0 -45
  145. package/src/util/git.ts +0 -32
  146. package/src/util/json.ts +0 -55
  147. package/src/util/log.ts +0 -76
  148. package/src/util/pool.ts +0 -48
  149. package/src/util/slug.ts +0 -26
  150. package/src/util/tui.ts +0 -335
  151. package/src/validate/index.ts +0 -123
  152. package/src/validate/integrity.ts +0 -387
  153. package/src/validate/reference.ts +0 -136
  154. package/src/validate/schema.ts +0 -328
  155. package/src/workspace/agents.ts +0 -290
  156. package/src/workspace/flows.ts +0 -191
  157. package/src/workspace/layout.ts +0 -165
  158. package/src/workspace/lock.ts +0 -148
  159. package/src/workspace/read.ts +0 -165
  160. package/src/workspace/reference.ts +0 -24
  161. package/src/workspace/stamp.ts +0 -301
  162. package/src/workspace/write.ts +0 -225
@@ -0,0 +1,234 @@
1
+ import { dim } from "../util/log.js";
2
+ import { IdMap } from "../apply/idmap.js";
3
+ const isNew = (row) => row['id'] === null || row['id'] === undefined || row['_new'] === true;
4
+ const adopt = (row, live) => {
5
+ /**
6
+ * The authored uuid is kept as tool metadata before being replaced.
7
+ *
8
+ * It is the only link between a reference written as `%{_new:<authored uuid>}`
9
+ * and the entity that now exists under a server-minted one. Discarding it
10
+ * without rewriting in the same pass strands every such reference permanently
11
+ * — which is exactly what happened once, and cost a hand edit to undo.
12
+ */
13
+ if (row['uuid'] && live['uuid'] && row['uuid'] !== live['uuid']) {
14
+ row['_authored_uuid'] = row['uuid'];
15
+ }
16
+ row['id'] = live['id'];
17
+ if (live['uuid'])
18
+ row['uuid'] = live['uuid'];
19
+ if (live['slug'])
20
+ row['slug'] = live['slug'];
21
+ delete row['_new'];
22
+ /**
23
+ * Fill in what the server filled in, and only that.
24
+ *
25
+ * An authored row states a label and a type; the pipe also holds `unique`,
26
+ * `card_synced`, `color_uuid` and the connector flags. Adopting the id without
27
+ * them leaves a row that the ordinary diff then reads as "set all of these to
28
+ * null" — a page of phantom changes on the next `pipe diff`. Keys the author
29
+ * did write are never touched, so an edit that has not been applied yet
30
+ * survives adoption.
31
+ */
32
+ for (const [k, v] of Object.entries(live)) {
33
+ if (k in row)
34
+ continue;
35
+ if (k.startsWith('_'))
36
+ continue;
37
+ row[k] = v;
38
+ }
39
+ };
40
+ /** uuid, else a natural key that does not include anything a create can get wrong. */
41
+ const keysOf = (row, natural) => {
42
+ const keys = [];
43
+ if (row['uuid'])
44
+ keys.push(`uuid:${String(row['uuid'])}`);
45
+ keys.push(`natural:${natural(row)}`);
46
+ return keys;
47
+ };
48
+ const matchInto = (entity, authored, live, natural, result,
49
+ /** Aliases → id, so references to what was adopted can be rewritten. */
50
+ resolved) => {
51
+ const byKey = new Map();
52
+ for (const l of live) {
53
+ for (const k of keysOf(l, natural))
54
+ if (!byKey.has(k))
55
+ byKey.set(k, l);
56
+ }
57
+ const claimed = new Set();
58
+ for (const row of authored) {
59
+ if (!isNew(row))
60
+ continue;
61
+ const label = String(row['name'] ?? row['label'] ?? entity);
62
+ let hit;
63
+ for (const k of keysOf(row, natural)) {
64
+ const candidate = byKey.get(k);
65
+ if (candidate && !claimed.has(candidate)) {
66
+ hit = candidate;
67
+ break;
68
+ }
69
+ }
70
+ if (!hit) {
71
+ result.unmatched.push({ entity, label });
72
+ continue;
73
+ }
74
+ claimed.add(hit);
75
+ const authoredUuid = row['uuid'] ? String(row['uuid']) : null;
76
+ const authoredLabel = String(row['label'] ?? row['name'] ?? '');
77
+ adopt(row, hit);
78
+ const id = Number(hit['id']);
79
+ result.stamped.push({ entity, label, id });
80
+ result.changed = true;
81
+ if (resolved && Number.isFinite(id)) {
82
+ // Both spellings an author may have used to name this entity.
83
+ const aliases = [authoredUuid ? `_new:${authoredUuid}` : '', authoredLabel ? `_new:${authoredLabel}` : ''].filter(Boolean);
84
+ for (const a of aliases)
85
+ resolved.resolvePlaceholder(a, id);
86
+ }
87
+ }
88
+ };
89
+ /**
90
+ * Groups the server owns outright, refreshed from live state whenever the author
91
+ * did not touch them.
92
+ *
93
+ * Creating a pipe's start form mints a `public_forms` row and a `visibilities`
94
+ * row that nobody asked for. After a partial apply the files are deliberately
95
+ * left as written, so those rows exist in the new baseline and not in the
96
+ * workspace — and the next diff reads that as *delete the public form*, marked
97
+ * destructive. The author never expressed an opinion about either row, so the
98
+ * honest resolution is to adopt what the server did.
99
+ *
100
+ * "Did not touch" is decided against the previous baseline, so an author who
101
+ * genuinely edited the public form keeps their edit.
102
+ */
103
+ const refreshUntouched = (authored, live, previous, result) => {
104
+ const same = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
105
+ const singletons = ['public_form', 'preferences'];
106
+ for (const key of singletons) {
107
+ if (same(authored[key], live[key]))
108
+ continue;
109
+ // With no previous baseline there is no way to tell an edit from a drift, so
110
+ // nothing is touched: a false negative here costs a phantom diff, a false
111
+ // positive silently discards someone's edit.
112
+ if (!previous || !same(authored[key], previous[key]))
113
+ continue;
114
+ authored[key] = live[key];
115
+ result.refreshed.push(key);
116
+ result.changed = true;
117
+ }
118
+ // Read-only groups are refreshed unconditionally: there is no edit to lose,
119
+ // because an edit to them is refused at plan time by path alone.
120
+ for (const key of ['email_templates', 'email_inboxes', 'visibilities']) {
121
+ if (same(authored.readonly[key], live.readonly[key]))
122
+ continue;
123
+ authored.readonly[key] = live.readonly[key];
124
+ result.refreshed.push(`_readonly/${key}`);
125
+ result.changed = true;
126
+ }
127
+ };
128
+ export const stampCreated = (authored, live, previous = null) => {
129
+ const result = { stamped: [], unmatched: [], refreshed: [], rewrittenReferences: 0, changed: false };
130
+ const resolved = new IdMap();
131
+ matchInto('phases', authored.phases, live.phases, (r) => String(r['name'] ?? ''), result, resolved);
132
+ /**
133
+ * Fields are matched within their phase where the phase is known, and across
134
+ * the whole pipe otherwise: a field created inside a brand-new phase has a
135
+ * null phase_id in the file it was authored in.
136
+ */
137
+ const liveFields = live.phases.flatMap((p) => p.fields.map((f) => ({ ...f, _phase_name: p['name'] })));
138
+ for (const phase of authored.phases) {
139
+ matchInto('fields', phase.fields, liveFields, (r) => String(r['label'] ?? ''), result, resolved);
140
+ }
141
+ matchInto('labels', authored.labels, live.labels, (r) => String(r['name'] ?? ''), result, resolved);
142
+ matchInto('webhooks', authored.webhooks, live.webhooks, (r) => `${String(r['name'] ?? '')}::${String(r['url'] ?? '')}`, result, resolved);
143
+ matchInto('pipe_relations', authored.pipe_relations, live.pipe_relations, (r) => `${String(r['parent_id'] ?? '')}::${String(r['child_id'] ?? '')}::${String(r['name'] ?? '')}`, result, resolved);
144
+ matchInto('automations', authored.automations, live.automations, (r) => String(r['name'] ?? ''), result, resolved);
145
+ matchInto('field_conditions', authored.field_conditions, live.field_conditions, (r) => String(r['name'] ?? ''), result, resolved);
146
+ refreshUntouched(authored, live, previous, result);
147
+ /**
148
+ * The resolution map is built from everything that exists on the pipe, not
149
+ * only from what this pass stamped.
150
+ *
151
+ * A placeholder can outlive the adoption of the thing it names: adopt a field's
152
+ * id in one run and the automation referring to it by `%{_new:<uuid>}` is still
153
+ * dangling in the next, with nothing left to match it against. Resolving
154
+ * against live state fixes it whenever it is noticed.
155
+ */
156
+ for (const p of authored.phases) {
157
+ for (const f of p.fields) {
158
+ const fid = Number(f['id']);
159
+ if (Number.isFinite(fid) && f['_authored_uuid']) {
160
+ resolved.resolvePlaceholder(`_new:${String(f['_authored_uuid'])}`, fid);
161
+ }
162
+ }
163
+ const pid = Number(p['id']);
164
+ if (Number.isFinite(pid) && p['_authored_uuid']) {
165
+ resolved.resolvePlaceholder(`_new:${String(p['_authored_uuid'])}`, pid);
166
+ }
167
+ }
168
+ for (const p of live.phases) {
169
+ const id = Number(p['id']);
170
+ if (!Number.isFinite(id))
171
+ continue;
172
+ if (p['uuid'])
173
+ resolved.resolvePlaceholder(`_new:${String(p['uuid'])}`, id);
174
+ if (p['name'])
175
+ resolved.resolvePlaceholder(`_new:${String(p['name'])}`, id);
176
+ for (const f of p.fields) {
177
+ const fid = Number(f['id']);
178
+ if (!Number.isFinite(fid))
179
+ continue;
180
+ if (f['uuid'])
181
+ resolved.resolvePlaceholder(`_new:${String(f['uuid'])}`, fid);
182
+ if (f['label'])
183
+ resolved.resolvePlaceholder(`_new:${String(f['label'])}`, fid);
184
+ }
185
+ }
186
+ /**
187
+ * Rewrite every reference to something that now exists. The IdMap does the
188
+ * deep walk — the same one the executor uses at send time — so an automation's
189
+ * `field_maps`, a condition's `field_address` and an `action_params` blob are
190
+ * all covered by one pass.
191
+ */
192
+ const before = JSON.stringify({
193
+ phases: authored.phases,
194
+ automations: authored.automations,
195
+ field_conditions: authored.field_conditions,
196
+ });
197
+ const rewritten = resolved.rewrite({
198
+ phases: authored.phases,
199
+ automations: authored.automations,
200
+ field_conditions: authored.field_conditions,
201
+ });
202
+ if (JSON.stringify(rewritten) !== before) {
203
+ authored.phases = rewritten.phases;
204
+ authored.automations = rewritten.automations;
205
+ authored.field_conditions = rewritten.field_conditions;
206
+ result.rewrittenReferences = [...before.matchAll(/%{_new:/g)].length;
207
+ result.changed = true;
208
+ }
209
+ return result;
210
+ };
211
+ export const renderStamp = (r) => {
212
+ const lines = [];
213
+ if (r.stamped.length) {
214
+ lines.push(`adopted ${r.stamped.length} server-assigned id${r.stamped.length === 1 ? '' : 's'} into the workspace, ` +
215
+ `so a re-plan will not create them again`);
216
+ for (const s of r.stamped.slice(0, 8))
217
+ lines.push(dim(` ${s.entity} ${s.label} → ${s.id}`));
218
+ if (r.stamped.length > 8)
219
+ lines.push(dim(` …and ${r.stamped.length - 8} more`));
220
+ }
221
+ if (r.rewrittenReferences) {
222
+ lines.push(`resolved ${r.rewrittenReferences} placeholder reference${r.rewrittenReferences === 1 ? '' : 's'} to those ids`);
223
+ }
224
+ if (r.refreshed.length) {
225
+ lines.push(`adopted live state for ${r.refreshed.length} group${r.refreshed.length === 1 ? '' : 's'} ` +
226
+ `the workspace never edited: ${r.refreshed.join(', ')}`);
227
+ }
228
+ if (r.unmatched.length) {
229
+ lines.push(`${r.unmatched.length} entit${r.unmatched.length === 1 ? 'y was' : 'ies were'} not created and remain new:`);
230
+ for (const u of r.unmatched.slice(0, 8))
231
+ lines.push(dim(` ${u.entity} ${u.label}`));
232
+ }
233
+ return lines;
234
+ };
@@ -0,0 +1,194 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { mkdirp, exists, writeJson } from "../util/fsx.js";
4
+ import { indexed, slug, uniquify } from "../util/slug.js";
5
+ import { repoPaths } from "./layout.js";
6
+ import { AGENT_DIR_TAG } from "./agents.js";
7
+ /**
8
+ * tree → files. PLAN.md §4:
9
+ * - fields live inside their phase file, because that is what Claude edits
10
+ * most and they only make sense in phase context
11
+ * - conditions and field_maps fold into their owner's file, matching how the
12
+ * mutations already take them nested
13
+ * - `_readonly/` is first-class so the planner can reject edits by path alone
14
+ */
15
+ const clearDir = async (dir) => {
16
+ await mkdirp(dir);
17
+ for (const name of await fs.readdir(dir)) {
18
+ if (name.endsWith('.json'))
19
+ await fs.rm(join(dir, name));
20
+ }
21
+ };
22
+ export const writeTree = async (dir, tree) => {
23
+ const p = repoPaths(dir, tree.meta.repoKind);
24
+ const written = [];
25
+ const put = async (path, value, kind) => {
26
+ await writeJson(path, value);
27
+ written.push({ path, kind });
28
+ };
29
+ await mkdirp(dir);
30
+ await put(p.meta, tree.meta, 'meta');
31
+ await put(p.repo, tree.repo, 'repo');
32
+ await clearDir(p.phasesDir);
33
+ const phaseName = uniquify();
34
+ for (const phase of tree.phases) {
35
+ const name = phaseName(indexed(Number(phase['index'] ?? 0), String(phase['name'] ?? '')));
36
+ await put(join(p.phasesDir, `${name}.json`), phase, 'phase');
37
+ }
38
+ /**
39
+ * A database gets no `automations/` or `field-conditions/` directory.
40
+ *
41
+ * Both are always empty for one — a database has no workflow to automate — and
42
+ * an empty directory is an invitation. Dropping a file into it would author a
43
+ * change that `pipe plan` can only refuse, so the honest thing is not to offer
44
+ * the slot. The arrays stay in the tree and round-trip as `[]`; it is only the
45
+ * directory that is absent.
46
+ */
47
+ const hasWorkflow = tree.meta.repoKind !== 'table';
48
+ if (hasWorkflow || tree.automations.length) {
49
+ await clearDir(p.automationsDir);
50
+ /**
51
+ * A behavior (an automation carrying `_agent_dir`, PLAN.md's agents/
52
+ * doc) is not written here — it goes under its agent's `behaviors/`
53
+ * instead, same file shape, different folder. Every agent directory that
54
+ * already exists gets its `behaviors/` cleared up front, same as
55
+ * `automations/` above, so a behavior deleted upstream (or reassigned to
56
+ * a different agent) does not leave a stale file behind.
57
+ */
58
+ const agentNames = (await exists(p.agentsDir)) ? await fs.readdir(p.agentsDir, { withFileTypes: true }) : [];
59
+ const agentDirs = agentNames.filter((e) => e.isDirectory()).map((e) => e.name);
60
+ for (const name of agentDirs)
61
+ await clearDir(join(p.agentsDir, name, 'behaviors'));
62
+ const autoName = uniquify();
63
+ const behaviorNameByDir = new Map();
64
+ for (const a of tree.automations) {
65
+ const agentDir = a[AGENT_DIR_TAG];
66
+ const { [AGENT_DIR_TAG]: _drop, ...row } = a;
67
+ if (agentDir) {
68
+ let behaviorName = behaviorNameByDir.get(agentDir);
69
+ if (!behaviorName) {
70
+ behaviorName = uniquify();
71
+ behaviorNameByDir.set(agentDir, behaviorName);
72
+ }
73
+ const dir = join(p.agentsDir, agentDir, 'behaviors');
74
+ await mkdirp(dir);
75
+ const name = behaviorName(slug(String(row['name'] ?? `behavior-${row['id']}`)));
76
+ await put(join(dir, `${name}.json`), row, 'behavior');
77
+ continue;
78
+ }
79
+ const name = autoName(slug(String(row['name'] ?? `automation-${row['id']}`)));
80
+ await put(join(p.automationsDir, `${name}.json`), row, 'automation');
81
+ }
82
+ }
83
+ if (hasWorkflow || tree.field_conditions.length) {
84
+ await clearDir(p.fieldConditionsDir);
85
+ const fcName = uniquify();
86
+ for (const fc of tree.field_conditions) {
87
+ const name = fcName(slug(String(fc['name'] ?? `condition-${fc['id']}`)));
88
+ await put(join(p.fieldConditionsDir, `${name}.json`), fc, 'field-condition');
89
+ }
90
+ }
91
+ await put(p.labels, tree.labels, 'labels');
92
+ await put(p.webhooks, tree.webhooks, 'webhooks');
93
+ await put(p.publicForm, tree.public_form, 'public-form');
94
+ await put(p.preferences, tree.preferences, 'preferences');
95
+ /**
96
+ * relations.json carries the writable `pipe_relations` plus a derived,
97
+ * read-only summary of where this pipe actually connects — which for the test
98
+ * pipe comes only from connector fields (PLAN.md §2.2). `_`-prefixed keys are
99
+ * tool metadata and are ignored on read.
100
+ */
101
+ const connectors = tree.phases
102
+ .flatMap((ph) => ph.fields.map((f) => ({ phase: ph['name'], field: f })))
103
+ .filter(({ field }) => field['type_id'] === 'connector')
104
+ .map(({ phase, field }) => ({
105
+ field_id: field['id'],
106
+ field_label: field['label'],
107
+ in_phase: phase,
108
+ connected_repo_id: field['connected_pipe_id'] ?? null,
109
+ can_create_connected_cards: field['can_create_connected_cards'] ?? null,
110
+ }));
111
+ await put(p.relations, {
112
+ pipe_relations: tree.pipe_relations,
113
+ _connector_fields: connectors,
114
+ _note: 'Connector fields do not imply pipe_relations rows and pipeMapGraph does not ' +
115
+ 'derive edges from them (PLAN.md §2.2). _connector_fields is derived on pull ' +
116
+ 'and ignored on read — edit the connector field inside its phase file instead.',
117
+ }, 'relations');
118
+ await clearDir(p.readonlyDir);
119
+ const ro = [
120
+ ['email-templates.json', tree.readonly.email_templates],
121
+ ['email-inboxes.json', tree.readonly.email_inboxes],
122
+ ['visibilities.json', tree.readonly.visibilities],
123
+ ['field-maps.json', tree.readonly.field_maps],
124
+ ];
125
+ for (const [name, rows] of ro)
126
+ await put(join(p.readonlyDir, name), rows, 'readonly');
127
+ await writeReadmes(p, tree.meta.repoKind);
128
+ return written;
129
+ };
130
+ const writeReadmes = async (p, kind = 'pipe') => {
131
+ /**
132
+ * A database gets its own README, because its files are honest but not
133
+ * self-explanatory.
134
+ *
135
+ * Pipefy stores a database as a repo: its columns are fields on a start form,
136
+ * and the phases after it are record statuses. That is the real shape and the
137
+ * codec round-trips it exactly — but nothing about `phases/01--ativo.json`
138
+ * tells a reader it is a status with no write path rather than a workflow step.
139
+ * The old code refused to write these files at all for that reason; writing
140
+ * them with an explanation is strictly better than reporting a gap.
141
+ */
142
+ if (kind === 'table') {
143
+ await fs.writeFile(join(p.dir, 'README.md'), [
144
+ '# This is a database, not a pipe',
145
+ '',
146
+ 'Pipefy stores a database as a repo, so these files have the shape of a pipe.',
147
+ 'That shape is real — it is what the API returns and what it accepts back — but',
148
+ 'two parts of it mean something different here.',
149
+ '',
150
+ '| Path | What it actually is |',
151
+ '| --- | --- |',
152
+ '| `table.json` | the database row. `noun` is what one record is called |',
153
+ '| `phases/00--start-form.json` | **the columns of the database.** Its `fields[]` are what you edit |',
154
+ '| `phases/NN--*.json` (the rest) | **record statuses**, not workflow steps. No public write path |',
155
+ '',
156
+ '## What can be changed',
157
+ '',
158
+ 'The database row and its columns. A column is created with',
159
+ '`createTableField`, changed with `updateTableField` and removed with',
160
+ '`deleteTableField` — all of which address a field by its **slug**, unlike the',
161
+ 'pipe mutations. Order is a separate `setTableFieldOrder` call, because',
162
+ '`createTableField` has no `index` input.',
163
+ '',
164
+ '## What cannot',
165
+ '',
166
+ '- **Record statuses.** Readable, and absent from `UpdateTableInput`.',
167
+ '- **Records.** They are the data of this database, as cards are the data of a pipe.',
168
+ ' They are deliberately not pulled, so an apply can never rewrite them.',
169
+ '- **Automations, field conditions and phase jumps.** A database has no',
170
+ ' workflow. If you find yourself authoring one here, the change belongs on a',
171
+ ' pipe.',
172
+ '',
173
+ '`pipe plan` refuses each of these by name rather than sending it.',
174
+ '',
175
+ ].join('\n'), 'utf8');
176
+ }
177
+ await fs.writeFile(join(p.readonlyDir, 'README.md'), [
178
+ '# Read-only entities',
179
+ '',
180
+ 'These have **no public write path** (PLAN.md §2). They are pulled so that',
181
+ 'Claude can read them and reason correctly, and any edit here is rejected by',
182
+ '`pipe validate` on the basis of the path alone.',
183
+ '',
184
+ '| File | Why read-only |',
185
+ '| --- | --- |',
186
+ '| `email-templates.json` | `EmailTemplate` is read-only; no Input types exist |',
187
+ '| `email-inboxes.json` | no mutation found |',
188
+ '| `visibilities.json` | only settable via `updatePipe.public_form`; `slug` is not settable |',
189
+ '| `field-maps.json` | a field’s own field_maps (`owner_type` other than `Automation`) — a connector ' +
190
+ 'field’s "copy this value from the connected card" configuration. No write path has been ' +
191
+ 'measured; edit the connector field in the product instead |',
192
+ '',
193
+ ].join('\n'), 'utf8');
194
+ };
package/package.json CHANGED
@@ -1,35 +1,38 @@
1
- {
2
- "name": "@pipefy/pipefy-process-coder",
3
- "version": "0.1.0",
4
- "description": "Pull a Pipefy pipe to disk as readable JSON, edit it with an agent, diff it, push it back.",
5
- "type": "module",
6
- "license": "UNLICENSED",
7
- "repository": {
8
- "type": "git",
9
- "url": "git+https://github.com/Guilherme-CA-Dias/pipefy-process-coder.git"
10
- },
11
- "publishConfig": {
12
- "access": "public"
13
- },
14
- "files": [
15
- "bin",
16
- "src",
17
- ".agents/skills",
18
- "AGENTS.md"
19
- ],
20
- "engines": {
21
- "node": ">=22.18"
22
- },
23
- "bin": {
24
- "pipe": "bin/pipe.js"
25
- },
26
- "scripts": {
27
- "pipe": "node src/cli.ts",
28
- "typecheck": "tsc -p tsconfig.json",
29
- "test": "node --test \"test/*.test.ts\""
30
- },
31
- "devDependencies": {
32
- "@types/node": "^22.7.0",
33
- "typescript": "^5.6.0"
34
- }
35
- }
1
+ {
2
+ "name": "@pipefy/pipefy-process-coder",
3
+ "version": "0.1.2",
4
+ "description": "Pull a Pipefy pipe to disk as readable JSON, edit it with an agent, diff it, push it back.",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Guilherme-CA-Dias/pipefy-process-coder.git"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "dist",
17
+ ".agents/skills",
18
+ "AGENTS.md",
19
+ "NOTICE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=22.18"
23
+ },
24
+ "bin": {
25
+ "pipe": "bin/pipe.js"
26
+ },
27
+ "scripts": {
28
+ "pipe": "node src/cli.ts",
29
+ "build": "node scripts/build.ts",
30
+ "prepack": "node scripts/build.ts",
31
+ "typecheck": "tsc -p tsconfig.json",
32
+ "test": "node --test \"test/*.test.ts\""
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.7.0",
36
+ "typescript": "^5.6.0"
37
+ }
38
+ }
@@ -1,150 +0,0 @@
1
- import type { Row } from '../model/payload.ts';
2
- import type { Tree } from '../model/tree.ts';
3
- import type { ChangeSet } from '../diff/diff.ts';
4
- import type { Plan } from './types.ts';
5
- import { changeByKey } from './types.ts';
6
- import type { IdMap } from './idmap.ts';
7
-
8
- /**
9
- * Adopting a created entity's real id without reading the pipe back.
10
- *
11
- * A create mutation's response already carries the id — that is where `IdMap`
12
- * gets it from, to substitute `%{_new:<uuid>}` into *later* steps in the same
13
- * run (idmap.ts). This is the other half: writing that same id into the
14
- * workspace file the entity was authored in, so it stops reading `"id": null,
15
- * "_new": true` and a re-plan does not create it a second time.
16
- *
17
- * No network read is needed because nothing about *which* row this is has to
18
- * be guessed: `change.after` (from the same ChangeSet apply.ts already
19
- * computed) is not a copy — `diff/diff.ts` builds every `Change` by reference
20
- * into the authored tree — so mutating it here mutates the tree that gets
21
- * written back.
22
- *
23
- * Scoped to creates on purpose. An update or delete that succeeded already
24
- * matches what the file states (that is what "succeeded" means for those
25
- * ops); a create is the only case where the file is missing information the
26
- * mutation response just supplied.
27
- */
28
-
29
- export type Adopted = { entity: string; label: string; id: number };
30
-
31
- export const adoptCreatedIds = (changeSet: ChangeSet, plan: Plan, idMap: IdMap): Adopted[] => {
32
- const adopted: Adopted[] = [];
33
-
34
- for (const step of plan.steps) {
35
- if (step.status !== 'done' || !step.produces) continue;
36
- const id = idMap.placeholderValue(step.produces.placeholder);
37
- if (id === undefined) continue;
38
-
39
- const change = changeByKey(changeSet, step.changeKey);
40
- if (!change || change.op !== 'create' || change.entity === 'repo' || !change.after) continue;
41
-
42
- const row = change.after as Row;
43
- if (Number(row['id']) === id && row['_new'] !== true) continue; // a resumed step, already adopted
44
- row['id'] = id;
45
- delete row['_new'];
46
- adopted.push({ entity: change.entity, label: change.label, id });
47
- }
48
-
49
- return adopted;
50
- };
51
-
52
- /**
53
- * Where an adopted row belongs in a baseline tree — additive only.
54
- *
55
- * A created entity is pure addition: the previous baseline has no row for it
56
- * at all, by definition, so there is nothing to overwrite and no unapplied
57
- * edit to lose. `entity` and `id` are enough to find it in the already
58
- * id-and-reference-resolved tree (`patched`, the output of `idMap.rewrite`),
59
- * because the id this run just adopted is unique within its array.
60
- */
61
- const findAdoptedRow = (patched: Tree, a: Adopted): Row | undefined => {
62
- switch (a.entity) {
63
- case 'phases':
64
- return patched.phases.find((p) => Number(p['id']) === a.id);
65
- case 'fields':
66
- return patched.phases.flatMap((p) => p.fields).find((f) => Number(f['id']) === a.id);
67
- case 'automations':
68
- return patched.automations.find((au) => Number(au['id']) === a.id);
69
- case 'labels':
70
- return patched.labels.find((l) => Number(l['id']) === a.id);
71
- case 'field_conditions':
72
- return patched.field_conditions.find((fc) => Number(fc['id']) === a.id);
73
- case 'webhooks':
74
- return patched.webhooks.find((w) => Number(w['id']) === a.id);
75
- case 'pipe_relations':
76
- return patched.pipe_relations.find((r) => Number(r['id']) === a.id);
77
- default:
78
- return undefined;
79
- }
80
- };
81
-
82
- /**
83
- * Merge every adopted row into a baseline tree, in the order they were
84
- * adopted — a new field's phase must land in `baseline.phases` before the
85
- * field itself is pushed into it, and `plan.steps` already runs creates in
86
- * that order (registry/compile.ts), so `adopted` does too.
87
- *
88
- * Mutates and returns `baseline`. Never touches a row baseline already has
89
- * an opinion about — this is append-only, which is what makes it safe without
90
- * a live read: the alternative (replaying an update or delete too) would
91
- * require knowing the *current* baseline row to update, which is exactly the
92
- * information a network read would supply and this path does not have.
93
- */
94
- export const mergeAdoptedIntoBaseline = (baseline: Tree, patched: Tree, adopted: Adopted[]): boolean => {
95
- let changed = false;
96
- for (const a of adopted) {
97
- const row = findAdoptedRow(patched, a);
98
- if (!row) continue;
99
- const clone = { ...row } as Row;
100
-
101
- switch (a.entity) {
102
- case 'phases':
103
- if (baseline.phases.some((p) => Number(p['id']) === a.id)) continue;
104
- baseline.phases.push(clone as Tree['phases'][number]);
105
- break;
106
- case 'fields': {
107
- const phaseId = Number(clone['phase_id']);
108
- const phase = baseline.phases.find((p) => Number(p['id']) === phaseId);
109
- if (!phase || phase.fields.some((f) => Number(f['id']) === a.id)) continue;
110
- phase.fields.push(clone);
111
- break;
112
- }
113
- case 'automations':
114
- if (baseline.automations.some((au) => Number(au['id']) === a.id)) continue;
115
- baseline.automations.push(clone as Tree['automations'][number]);
116
- break;
117
- case 'labels':
118
- if (baseline.labels.some((l) => Number(l['id']) === a.id)) continue;
119
- baseline.labels.push(clone);
120
- break;
121
- case 'field_conditions':
122
- if (baseline.field_conditions.some((fc) => Number(fc['id']) === a.id)) continue;
123
- baseline.field_conditions.push(clone as Tree['field_conditions'][number]);
124
- break;
125
- case 'webhooks':
126
- if (baseline.webhooks.some((w) => Number(w['id']) === a.id)) continue;
127
- baseline.webhooks.push(clone);
128
- break;
129
- case 'pipe_relations':
130
- if (baseline.pipe_relations.some((r) => Number(r['id']) === a.id)) continue;
131
- baseline.pipe_relations.push(clone);
132
- break;
133
- default:
134
- continue;
135
- }
136
- changed = true;
137
- }
138
- return changed;
139
- };
140
-
141
- export const renderAdopted = (adopted: Adopted[]): string[] => {
142
- if (!adopted.length) return [];
143
- const lines = [
144
- `adopted ${adopted.length} server-assigned id${adopted.length === 1 ? '' : 's'} directly from the create ` +
145
- `response(s) — no read-back needed, so a re-plan will not create them again`,
146
- ];
147
- for (const a of adopted.slice(0, 8)) lines.push(` ${a.entity} ${a.label} → ${a.id}`);
148
- if (adopted.length > 8) lines.push(` …and ${adopted.length - 8} more`);
149
- return lines;
150
- };