@pipefy/pipefy-process-coder 0.1.0 → 0.1.1
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 -262
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -247
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -589
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -1391
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -623
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -636
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -3642
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -863
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -799
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -42
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -581
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -503
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -562
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -557
- package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -609
- package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -146
- package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -127
- package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -91
- package/AGENTS.md +465 -456
- package/README.md +825 -807
- package/bin/pipe.js +25 -4
- package/dist/apply/adopt.js +128 -0
- package/dist/apply/agentops.js +42 -0
- package/dist/apply/compile.js +722 -0
- package/dist/apply/execute.js +277 -0
- package/dist/apply/flowops.js +293 -0
- package/dist/apply/idmap.js +233 -0
- package/dist/apply/mutations.js +704 -0
- package/dist/apply/registry.js +348 -0
- package/dist/apply/types.js +18 -0
- package/dist/cli/args.js +79 -0
- package/dist/cli.js +187 -0
- package/dist/codec/flow.js +164 -0
- package/dist/codec/pack.js +88 -0
- package/dist/codec/roundtrip.js +86 -0
- package/dist/codec/unpack.js +181 -0
- package/dist/commands/agents.js +145 -0
- package/dist/commands/apply.js +982 -0
- package/dist/commands/context.js +79 -0
- package/dist/commands/create.js +63 -0
- package/dist/commands/diff.js +294 -0
- package/dist/commands/flows.js +348 -0
- package/dist/commands/misc.js +528 -0
- package/dist/commands/plan.js +301 -0
- package/dist/commands/pull.js +511 -0
- package/dist/commands/runs.js +74 -0
- package/dist/commands/skills.js +131 -0
- package/dist/commands/verify.js +205 -0
- package/dist/config.js +125 -0
- package/dist/diff/agents.js +80 -0
- package/dist/diff/diff.js +983 -0
- package/dist/diff/flow.js +258 -0
- package/{src/diff/html.ts → dist/diff/html.js} +274 -322
- package/dist/diff/render.js +90 -0
- package/dist/model/payload.js +84 -0
- package/dist/model/tree.js +7 -0
- package/dist/model/volatile.js +54 -0
- package/dist/pipefy/agents.js +122 -0
- package/dist/pipefy/automations.js +155 -0
- package/dist/pipefy/capability.js +84 -0
- package/dist/pipefy/client.js +204 -0
- package/dist/pipefy/discovery.js +148 -0
- package/dist/pipefy/internal.js +315 -0
- package/dist/pipefy/ipaas.js +269 -0
- package/dist/pipefy/reconstruct.js +674 -0
- package/dist/pipefy/reference.js +172 -0
- package/dist/pipefy/snapshot.js +185 -0
- package/dist/pipefy/toolkit.js +158 -0
- package/dist/report/integrations.js +165 -0
- package/dist/report/run.js +355 -0
- package/dist/util/fsx.js +50 -0
- package/dist/util/git.js +25 -0
- package/dist/util/json.js +56 -0
- package/dist/util/log.js +69 -0
- package/dist/util/pool.js +42 -0
- package/dist/util/slug.js +23 -0
- package/dist/util/tui.js +287 -0
- package/dist/validate/index.js +86 -0
- package/dist/validate/integrity.js +370 -0
- package/dist/validate/reference.js +120 -0
- package/dist/validate/schema.js +278 -0
- package/dist/workspace/agents.js +245 -0
- package/{src/workspace/docs.ts → dist/workspace/docs.js} +367 -407
- package/dist/workspace/flows.js +149 -0
- package/dist/workspace/layout.js +146 -0
- package/dist/workspace/lock.js +83 -0
- package/dist/workspace/read.js +168 -0
- package/dist/workspace/reference.js +24 -0
- package/dist/workspace/stamp.js +234 -0
- package/dist/workspace/write.js +194 -0
- package/package.json +37 -35
- package/src/apply/adopt.ts +0 -150
- package/src/apply/agentops.ts +0 -71
- package/src/apply/compile.ts +0 -875
- package/src/apply/execute.ts +0 -336
- package/src/apply/flowops.ts +0 -399
- package/src/apply/idmap.ts +0 -241
- package/src/apply/mutations.ts +0 -955
- package/src/apply/registry.ts +0 -430
- package/src/apply/types.ts +0 -134
- package/src/cli/args.ts +0 -88
- package/src/cli.ts +0 -211
- package/src/codec/flow.ts +0 -199
- package/src/codec/pack.ts +0 -103
- package/src/codec/roundtrip.ts +0 -94
- package/src/codec/unpack.ts +0 -198
- package/src/commands/agents.ts +0 -184
- package/src/commands/apply.ts +0 -1144
- package/src/commands/context.ts +0 -119
- package/src/commands/create.ts +0 -79
- package/src/commands/diff.ts +0 -314
- package/src/commands/flows.ts +0 -414
- package/src/commands/misc.ts +0 -644
- package/src/commands/plan.ts +0 -331
- package/src/commands/pull.ts +0 -567
- package/src/commands/runs.ts +0 -83
- package/src/commands/skills.ts +0 -137
- package/src/commands/verify.ts +0 -253
- package/src/config.ts +0 -168
- package/src/diff/agents.ts +0 -122
- package/src/diff/diff.ts +0 -1130
- package/src/diff/flow.ts +0 -318
- package/src/diff/render.ts +0 -101
- package/src/model/payload.ts +0 -154
- package/src/model/tree.ts +0 -99
- package/src/model/volatile.ts +0 -55
- package/src/pipefy/agents.ts +0 -165
- package/src/pipefy/automations.ts +0 -219
- package/src/pipefy/capability.ts +0 -119
- package/src/pipefy/client.ts +0 -267
- package/src/pipefy/discovery.ts +0 -209
- package/src/pipefy/internal.ts +0 -380
- package/src/pipefy/ipaas.ts +0 -365
- package/src/pipefy/reconstruct.ts +0 -775
- package/src/pipefy/reference.ts +0 -251
- package/src/pipefy/snapshot.ts +0 -245
- package/src/pipefy/toolkit.ts +0 -200
- package/src/report/integrations.ts +0 -231
- package/src/report/run.ts +0 -475
- package/src/util/fsx.ts +0 -45
- package/src/util/git.ts +0 -32
- package/src/util/json.ts +0 -55
- package/src/util/log.ts +0 -76
- package/src/util/pool.ts +0 -48
- package/src/util/slug.ts +0 -26
- package/src/util/tui.ts +0 -335
- package/src/validate/index.ts +0 -123
- package/src/validate/integrity.ts +0 -387
- package/src/validate/reference.ts +0 -136
- package/src/validate/schema.ts +0 -328
- package/src/workspace/agents.ts +0 -290
- package/src/workspace/flows.ts +0 -191
- package/src/workspace/layout.ts +0 -165
- package/src/workspace/lock.ts +0 -148
- package/src/workspace/read.ts +0 -165
- package/src/workspace/reference.ts +0 -24
- package/src/workspace/stamp.ts +0 -301
- package/src/workspace/write.ts +0 -225
- /package/{src → dist}/pipefy/toolkit_bearer.py +0 -0
|
@@ -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,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@pipefy/pipefy-process-coder",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
"
|
|
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
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@pipefy/pipefy-process-coder",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"dist",
|
|
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
|
+
"build": "node scripts/build.ts",
|
|
29
|
+
"prepack": "node scripts/build.ts",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "node --test \"test/*.test.ts\""
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.7.0",
|
|
35
|
+
"typescript": "^5.6.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/apply/adopt.ts
DELETED
|
@@ -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
|
-
};
|