@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,549 @@
1
+ import { resolve } from 'node:path';
2
+ import { bool, int, str, requirePositional } from "../cli/args.js";
3
+ import { mkdirp, writeFile, writeJson } from "../util/fsx.js";
4
+ import { bold, dim, green, info, ok, out, step, UserError, warn, yellow } from "../util/log.js";
5
+ import { fullCoverage } from "../model/payload.js";
6
+ import { unpack } from "../codec/unpack.js";
7
+ import { Progress } from "../util/tui.js";
8
+ import { assertRoundTrip } from "../codec/roundtrip.js";
9
+ import { discover, resolveRepo } from "../pipefy/discovery.js";
10
+ import { pullAndWriteAgents } from "./agents.js";
11
+ import { foldFreshBehaviors, foldBehaviorsIntoEnvelope } from "../workspace/agents.js";
12
+ import { repoDir } from "../workspace/layout.js";
13
+ import { emptyLock, upsertRepo, writeLock, writeWorkspace } from "../workspace/lock.js";
14
+ import { writeTree } from "../workspace/write.js";
15
+ import { writeDocs } from "../workspace/docs.js";
16
+ import { readReference } from "../pipefy/reference.js";
17
+ import { loadReference } from "../workspace/reference.js";
18
+ import { baselineFile, paths } from "../workspace/layout.js";
19
+ import { connect } from "./context.js";
20
+ import { RunRecorder, invocation } from "../report/run.js";
21
+ import { pullAndWriteFlows } from "./flows.js";
22
+ import { TOOL } from "../version.js";
23
+ /**
24
+ * `pipe pull <pipeId>` — the read path.
25
+ *
26
+ * Snapshots when the organization has them, reconstruction over GraphQL and the
27
+ * internal API when it does not. Both produce the same payload shape, so
28
+ * everything downstream is identical; only `_meta.json` and the coverage map
29
+ * record which one happened.
30
+ *
31
+ * A pull always costs at least one snapshot when snapshots are used, because a
32
+ * snapshot of an unchanged pipe still creates a new row and a new S3 object
33
+ * (PLAN.md §1.1). `--reuse-snapshot` uses the newest complete one instead, which
34
+ * costs nothing.
35
+ */
36
+ export const help = `pipe pull <pipeId> [options]
37
+
38
+ --out <dir> workspace directory (default: ./<pipe-name>-workspace)
39
+ --depth <n> how far to walk connected repos (default 1)
40
+ --org <id> organization id, for the internal automations query
41
+ --reuse-snapshot use the newest complete snapshot instead of creating one
42
+ --no-snapshot force GraphQL reconstruction even if snapshots exist
43
+ --no-fallback if a snapshot read fails, fail rather than reconstruct over GraphQL
44
+ --no-internal skip the internal API (automations and jumps become unknown)
45
+ --label <text> label the snapshot this pull creates
46
+ --skip-roundtrip do not run the codec self-check (not recommended)
47
+ --no-flows skip the pipe's iPaaS flows (they are read by default)
48
+ --report write a run report into runs/ (payload copy included)
49
+ --from-file <path> build the workspace from an exported payload instead of the
50
+ network. Pairs with \`pipe export\`, needs no token, and is how
51
+ you work on a pipe you can only read once.`;
52
+ export const run = async (p) => {
53
+ const fromFile = str(p, 'from-file');
54
+ if (fromFile)
55
+ return pullFromFile(p, fromFile);
56
+ const repoIdArg = requirePositional(p, 0, 'a pipe or database id');
57
+ const depth = int(p, 'depth', 1);
58
+ const forceReconstruct = bool(p, 'no-snapshot');
59
+ const noInternal = bool(p, 'no-internal');
60
+ const reuseSnapshot = bool(p, 'reuse-snapshot');
61
+ const skipRoundTrip = bool(p, 'skip-roundtrip');
62
+ const noFallback = bool(p, 'no-fallback');
63
+ const label = str(p, 'label');
64
+ const startedAt = new Date();
65
+ const net = await connect(p);
66
+ const caps = await net.capabilities();
67
+ const useSnapshots = caps.snapshots && !forceReconstruct;
68
+ /**
69
+ * Resolve the argument before anything else uses it.
70
+ *
71
+ * A database answers to two ids — the numeric internal one that connector
72
+ * fields carry, and the api id that the API returns — so both spellings have
73
+ * to work on the command line. Everything downstream keys on the numeric form
74
+ * (`discover`, the caches, `emptyLock`, `baselineFile`), so the raw argument is
75
+ * resolved once here and never threaded past this point. That is also what
76
+ * stops one database being pulled twice under two identities.
77
+ */
78
+ const resolved = await resolveRepo(net.client, repoIdArg);
79
+ if (!resolved) {
80
+ throw new UserError(`"${repoIdArg}" is not a repo this token can see`, 'pass a pipe id, a database id, or a database api id such as dzmxcuxz');
81
+ }
82
+ const rootRepoId = resolved.id;
83
+ info('');
84
+ info(bold(`pipe pull ${rootRepoId}` +
85
+ (resolved.kind === 'table' ? ` ${dim(`database ${resolved.apiId}`)}` : '')));
86
+ info(dim(`read path: ${useSnapshots ? (reuseSnapshot ? 'existing snapshots' : 'new snapshots') : 'GraphQL reconstruction'}` +
87
+ `${caps.snapshots ? '' : ' (this endpoint has no snapshots)'}` +
88
+ ` · depth ${depth}${noInternal ? ' · internal API skipped' : ''}`));
89
+ info('');
90
+ /**
91
+ * One read of one repo, whichever path applies, cached for discovery reuse.
92
+ *
93
+ * Failures are cached too. Discovery reads a repo and then the write loop
94
+ * reads it again; without caching the failure, a repo that could not be read
95
+ * gets a *second* snapshot created for it — and a snapshot is a fresh S3
96
+ * object every time (PLAN.md §1.1). One failed pull should cost one snapshot,
97
+ * not two.
98
+ */
99
+ const cache = new Map();
100
+ const failed = new Set();
101
+ const readRepo = async (ref) => {
102
+ const hit = cache.get(ref.id);
103
+ if (hit)
104
+ return hit;
105
+ if (failed.has(ref.id))
106
+ return null;
107
+ try {
108
+ let env;
109
+ /**
110
+ * Databases take the snapshot path too.
111
+ *
112
+ * This used to refuse them, on the grounds that ask 10 — whether
113
+ * `createRepoSnapshot` accepts a table id — was untested and nobody wanted
114
+ * to find out on a client's data. It was tested on a five-record throwaway:
115
+ * it does, `SnapshotClient` needed no change because it reaches snapshots
116
+ * through `pipe(id:)`, and the payload that comes back has the same twenty
117
+ * relation arrays a pipe's does (VALIDATION.md §3g).
118
+ */
119
+ if (useSnapshots) {
120
+ try {
121
+ env = await readViaSnapshot(net, ref, { reuseSnapshot, label });
122
+ }
123
+ catch (e) {
124
+ /**
125
+ * A failed snapshot read falls back to reconstruction rather than
126
+ * leaving the repo out. Found live: Pipefy's storage answered every
127
+ * signed snapshot URL with HTTP 500 — the creation succeeded, the
128
+ * download did not, on every pipe tried — and the pull wrote a
129
+ * workspace with no pipe in it. Reconstruction is less complete, and
130
+ * says so per group: what it cannot see is `unknown`, never empty, so
131
+ * the fallback cannot license a delete.
132
+ */
133
+ if (noFallback)
134
+ throw e;
135
+ warn(`snapshot read failed for ${ref.kind} ${ref.id} (${ref.name ?? '?'}): ${e.message} — ` +
136
+ 'reading it over GraphQL instead (--no-fallback to stop here)');
137
+ env = await net.reconstructor.reconstruct(ref.id, {
138
+ organizationId: net.organizationId,
139
+ noInternal,
140
+ });
141
+ }
142
+ }
143
+ else {
144
+ env = await net.reconstructor.reconstruct(ref.id, {
145
+ organizationId: net.organizationId,
146
+ noInternal,
147
+ });
148
+ }
149
+ cache.set(ref.id, env);
150
+ return env;
151
+ }
152
+ catch (e) {
153
+ failed.add(ref.id);
154
+ warn(`could not read ${ref.kind} ${ref.id} (${ref.name ?? '?'}): ${e.message}`);
155
+ return null;
156
+ }
157
+ };
158
+ /**
159
+ * A pull is mostly waiting: ~63s per snapshot, plus an N+1 automation read on
160
+ * the reconstruction path. One task per repo, with the current stage as its
161
+ * detail, is what makes that legible rather than looking hung.
162
+ */
163
+ const ui = new Progress({ title: `pulling repo ${rootRepoId}` });
164
+ ui.add({ id: 'discover', label: 'discovering connected repos', state: 'running' });
165
+ ui.start();
166
+ const repoTask = (ref) => {
167
+ const id = `repo:${ref.id}`;
168
+ if (!ui.has(id))
169
+ ui.add({ id, label: `${ref.kind} ${ref.id} ${ref.name ?? ''}`.trim(), depth: 1 });
170
+ return id;
171
+ };
172
+ let currentTask = null;
173
+ net.snapshots.onProgress = (text) => {
174
+ if (currentTask)
175
+ ui.update(currentTask, { detail: text });
176
+ };
177
+ const discovery = await discover(net.client, rootRepoId, {
178
+ depth,
179
+ read: async (ref) => {
180
+ currentTask = repoTask(ref);
181
+ ui.update(currentTask, { state: 'running', detail: 'reading' });
182
+ return (await readRepo(ref))?.payload ?? null;
183
+ },
184
+ });
185
+ ui.update('discover', {
186
+ state: 'done',
187
+ detail: `${discovery.repos.length} repo${discovery.repos.length === 1 ? '' : 's'}`,
188
+ });
189
+ const rootRef = discovery.repos.find((r) => r.id === rootRepoId);
190
+ const outDir = resolve(str(p, 'out') ?? `${slugName(rootRef.name ?? String(rootRepoId))}-workspace`);
191
+ /**
192
+ * The root repo is the workspace. Without it there is nothing to validate,
193
+ * diff or plan against — and writing lock.json and workspace.json anyway
194
+ * did worse than produce an empty directory: re-pulling into an existing
195
+ * workspace replaced its lock with one that no longer listed the pipe, so
196
+ * the next diff had no baseline. This used to finish with exit code 0 and a
197
+ * "next: pipe validate", which scripts and agents believed.
198
+ */
199
+ if (!(await readRepo(rootRef))) {
200
+ ui.stop();
201
+ throw new UserError(`could not read the root repo ${rootRepoId}${rootRef.name ? ` (${rootRef.name})` : ''} — nothing was written to ${outDir}`, useSnapshots && noFallback
202
+ ? 'drop --no-fallback to read it over GraphQL, or run with --verbose for the cause'
203
+ : 'run with --verbose for the cause');
204
+ }
205
+ await mkdirp(outDir);
206
+ const lock = emptyLock(rootRepoId, net.organizationId, depth);
207
+ lock.edges = discovery.edges;
208
+ const trees = [];
209
+ const sources = new Set();
210
+ const flowSummaries = [];
211
+ const flowNotes = [];
212
+ const agentNotes = [];
213
+ for (const ref of discovery.repos) {
214
+ let env = await readRepo(ref);
215
+ if (!env)
216
+ continue;
217
+ if (!skipRoundTrip) {
218
+ const rt = assertRoundTrip(env);
219
+ if (!rt.ok) {
220
+ warn(`codec round trip is not clean for ${ref.kind} ${ref.id}:`);
221
+ for (const d of rt.differences.slice(0, 8))
222
+ warn(` ${d.where}: ${d.detail}`);
223
+ warn('planning will refuse on this repo until the codec is fixed — see src/codec/');
224
+ }
225
+ }
226
+ const tree = unpack(env);
227
+ const task = repoTask(ref);
228
+ ui.update(task, { state: 'running', detail: 'writing files' });
229
+ const dir = repoDir(outDir, ref);
230
+ /**
231
+ * The repo's AI agents, read and written *before* the tree — agents.json
232
+ * files must exist and `automations/` has to know which rows are actually
233
+ * behaviors before `writeTree` decides where each one goes.
234
+ *
235
+ * Not in the payload at all (ROUTE-B-V2.md ask 5), so fetched separately and
236
+ * kept in `agents/`, mirroring `flows/`. Recording whether the read
237
+ * succeeded is what lets the validator distinguish "this pipe has no
238
+ * agents" from "I could not read them" — and only refuse an AI-referencing
239
+ * automation in the first case.
240
+ */
241
+ if (ref.kind === 'pipe') {
242
+ ui.update(task, { detail: 'reading AI agents' });
243
+ try {
244
+ const agentsSection = await pullAndWriteAgents(net.client, ref.id, tree.repo['uuid'], dir, {
245
+ workspaceRoot: outDir,
246
+ });
247
+ if (agentsSection.meta.coverage === 'complete') {
248
+ tree.automations = foldFreshBehaviors(tree.automations, agentsSection.agents);
249
+ /**
250
+ * The baseline gets the same replacement, or it is left holding the
251
+ * snapshot's stub forever — `pipe diff` would report a phantom
252
+ * change on every behavior on every pull, since the workspace files
253
+ * and the baseline could never agree.
254
+ */
255
+ env = foldBehaviorsIntoEnvelope(env, agentsSection.agents);
256
+ }
257
+ else {
258
+ agentNotes.push(`${ref.name ?? ref.id}: agents unknown — ${agentsSection.meta.reason ?? agentsSection.meta.notes.join('; ') ?? 'not readable'}`);
259
+ }
260
+ }
261
+ catch (e) {
262
+ agentNotes.push(`${ref.name ?? ref.id}: agents could not be read — ${e.message}`);
263
+ }
264
+ }
265
+ trees.push(tree);
266
+ sources.add(env.source);
267
+ const written = await writeTree(dir, tree);
268
+ await writeJson(baselineFile(outDir, ref.id), env);
269
+ upsertRepo(lock, ref, outDir, tree);
270
+ /**
271
+ * The repo's iPaaS flows, read as part of the same pull.
272
+ *
273
+ * Not behind a flag because they are not an extra: a flow lives in exactly
274
+ * one pipe's iPaaS workspace, and this pipe's own webhook URLs carry the ids
275
+ * of its flows. Reading the automations without them gives a wrong picture of
276
+ * what the process does — iPaaS is where a process reaches other pipes and
277
+ * other systems, which the automation engine cannot.
278
+ *
279
+ * A pipe without iPaaS costs one refused token mint and records 'unknown'.
280
+ */
281
+ if (!bool(p, 'no-flows') && ref.kind === 'pipe') {
282
+ ui.update(task, { detail: 'reading iPaaS flows' });
283
+ try {
284
+ const flowsSection = await pullAndWriteFlows(net.ipaas, ref.id, dir, { workspaceRoot: outDir });
285
+ if (flowsSection.meta.coverage === 'complete') {
286
+ flowSummaries.push(`${ref.name ?? ref.id}: ${flowsSection.flows.length} flow(s), ${flowsSection.connections.length} connection(s)`);
287
+ for (const n of flowsSection.meta.notes)
288
+ flowNotes.push(n);
289
+ }
290
+ else {
291
+ flowNotes.push(`${ref.name ?? ref.id}: flows unknown — ${flowsSection.meta.reason ?? 'not readable'}`);
292
+ }
293
+ }
294
+ catch (e) {
295
+ flowNotes.push(`${ref.name ?? ref.id}: flows could not be read — ${e.message}`);
296
+ }
297
+ }
298
+ const gaps = Object.entries(env.coverage).filter(([, c]) => c !== 'complete');
299
+ const fieldCount = tree.phases.reduce((n, ph) => n + ph.fields.length, 0);
300
+ /**
301
+ * A database is counted in its own terms.
302
+ *
303
+ * Its payload is a pipe's, so it does have phases — a start form plus one
304
+ * per record status. Reporting "3 phases" for a database with three columns
305
+ * and two statuses would be true and useless, so the statuses are named as
306
+ * what they are.
307
+ */
308
+ const detail = ref.kind === 'table'
309
+ ? `${fieldCount} fields, ${Math.max(0, tree.phases.length - 1)} record statuses, ${written.length} files`
310
+ : `${tree.phases.length} phases, ${fieldCount} fields, ` +
311
+ `${tree.automations.length} automations, ${written.length} files`;
312
+ ui.update(task, {
313
+ state: 'done',
314
+ detail: detail + (gaps.length ? `, ${gaps.length} groups partial or unknown` : ''),
315
+ });
316
+ }
317
+ await writeLock(outDir, lock);
318
+ await writeWorkspace(outDir, {
319
+ name: rootRef.name ?? String(rootRepoId),
320
+ createdAt: new Date().toISOString(),
321
+ rootRepoId,
322
+ organizationId: net.organizationId,
323
+ depth,
324
+ lastPullSource: sources.size > 1 ? 'mixed' : (sources.values().next().value ?? 'reconstructed'),
325
+ tool: { ...TOOL },
326
+ });
327
+ /**
328
+ * Read the closed sets while the client is open. Two queries, and it is what
329
+ * lets validate refuse a bad enum value or automation parameter offline with
330
+ * the value named, instead of the API answering `is invalid` partway through
331
+ * an apply.
332
+ */
333
+ const reference = await readReference(net.client, rootRepoId);
334
+ if (reference)
335
+ await writeJson(paths(outDir).reference, reference);
336
+ await writeDocs(outDir, lock, trees, caps, reference);
337
+ ui.stop();
338
+ /**
339
+ * A pull records itself only when asked. The workspace it just wrote already
340
+ * *is* the pipe, and the baseline envelope under `.ppc/baseline/` already is
341
+ * the payload, so a report here duplicates both. It earns its place when a
342
+ * sequence of runs is being kept side by side: this is the one that captures
343
+ * the pipe as it was before anyone edited anything.
344
+ */
345
+ if (bool(p, 'report')) {
346
+ const rootEnv = cache.get(rootRepoId) ?? null;
347
+ const rec = new RunRecorder('pull', {
348
+ workspace: outDir,
349
+ repo: {
350
+ id: rootRepoId,
351
+ uuid: rootRef.uuid ?? null,
352
+ name: rootRef.name ?? String(rootRepoId),
353
+ kind: rootRef.kind,
354
+ },
355
+ command: invocation(),
356
+ startedAt,
357
+ });
358
+ rec.after(rootEnv);
359
+ rec.note(`${discovery.repos.length} repo(s) discovered at depth ${depth}; ` +
360
+ `${trees.length} written (${lock.repos.filter((r) => r.kind === 'table').length} database(s))`);
361
+ for (const t of trees)
362
+ for (const n of t.meta.notes)
363
+ rec.note(n);
364
+ const res = await rec.write(rootEnv ? 'ok' : 'failed', rootEnv
365
+ ? `pulled ${trees.length} repo(s) via ${[...sources].join(' + ')}`
366
+ : `could not read the root repo ${rootRepoId}`);
367
+ if (res?.error)
368
+ warn(`could not write the run report: ${res.error}`);
369
+ else if (res)
370
+ info(dim(`run recorded in runs/${rec.id}/`));
371
+ }
372
+ info('');
373
+ out(bold(outDir));
374
+ out(dim(' AGENTS.md · MAPPING.md · CAPABILITIES.md · REFERENCE.md written'));
375
+ if (flowSummaries.length) {
376
+ info('');
377
+ for (const s of flowSummaries)
378
+ ok(`iPaaS ${s}`);
379
+ }
380
+ for (const n of flowNotes)
381
+ info(dim(` ${n}`));
382
+ for (const n of agentNotes)
383
+ info(dim(` ${n}`));
384
+ const databases = lock.repos.filter((r) => r.kind === 'table');
385
+ if (databases.length) {
386
+ info('');
387
+ ok(`${databases.length} database(s): ` +
388
+ databases.map((r) => `${r.name} (${r.apiId})`).join(', '));
389
+ info(dim(' a database is a repo: its columns are fields on a start form, and the phases'));
390
+ info(dim(' after it are record statuses. Neither the statuses nor the records can be written.'));
391
+ }
392
+ const anyUnknown = lock.repos.flatMap((r) => Object.entries(r.coverage).filter(([, c]) => c === 'unknown').map(([e]) => e));
393
+ if (anyUnknown.length) {
394
+ const distinct = [...new Set(anyUnknown)].sort();
395
+ info('');
396
+ warn(`${distinct.length} entity group(s) are unknown, not empty: ${distinct.join(', ')}`);
397
+ info(dim(' They are never diffed and never applied. See AGENTS.md for what that means for edits.'));
398
+ }
399
+ const hasSecrets = trees.some((t) => t.webhooks.length > 0 || JSON.stringify(t.automations).includes('authentication'));
400
+ if (hasSecrets) {
401
+ info('');
402
+ warn('this workspace contains webhook URLs or HTTP-request credentials — possession is authorization');
403
+ info(dim(' a .gitignore was written; do not commit or share these files'));
404
+ }
405
+ info('');
406
+ info(`${yellow('next')} ${dim('cd')} ${outDir} ${dim('&&')} pipe validate`);
407
+ };
408
+ const readViaSnapshot = async (net, ref, opts) => {
409
+ let versionId;
410
+ if (opts.reuseSnapshot) {
411
+ const latest = await net.snapshots.latestComplete(ref.id);
412
+ if (!latest)
413
+ throw new Error('no complete snapshot exists to reuse — drop --reuse-snapshot');
414
+ versionId = latest.versionId;
415
+ step(`reusing snapshot ${versionId.slice(0, 8)} of ${ref.name ?? ref.id}`);
416
+ }
417
+ else {
418
+ versionId = await net.snapshots.create(ref.id);
419
+ await net.snapshots.waitFor(ref.id, versionId);
420
+ if (opts.label)
421
+ await net.snapshots.rename(ref.id, versionId, opts.label);
422
+ }
423
+ const { payload, gzip } = await net.snapshots.download(ref.id, versionId);
424
+ return {
425
+ source: 'snapshot',
426
+ versionId,
427
+ readAt: new Date().toISOString(),
428
+ repoKind: ref.kind,
429
+ coverage: fullCoverage('complete'),
430
+ notes: [],
431
+ payload,
432
+ ...(gzip ? { _gzip: gzip } : {}),
433
+ };
434
+ };
435
+ /*
436
+ * There was a `cacheSnapshotBytes` here, writing the raw .gz into
437
+ * `.ppc/snapshots/`. Nothing ever called it, so the directory was never created
438
+ * and the feature it implied — a local snapshot cache — did not exist.
439
+ *
440
+ * It is not missed. A re-pull already costs nothing with `--reuse-snapshot`,
441
+ * which reads the newest complete snapshot Pipefy already holds, and `pipe export`
442
+ * plus `pipe pull --from-file` covers working with no network at all. The baseline
443
+ * envelope keeps the payload itself, which is the part anything downstream reads.
444
+ */
445
+ const slugName = (s) => s
446
+ .toLowerCase()
447
+ .replace(/[^a-z0-9]+/g, '-')
448
+ .replace(/^-+|-+$/g, '')
449
+ .slice(0, 40) || 'pipefy';
450
+ export const writeRawPayload = (path, env) => writeFile(path, JSON.stringify(env.payload, null, 2));
451
+ /**
452
+ * Build a workspace from an exported payload, with no network at all.
453
+ *
454
+ * Two reasons this is a first-class path rather than a test hook: a snapshot
455
+ * costs ~63s and a fresh S3 object every time (PLAN.md §1.1), and an FDE often
456
+ * gets one read of a client's pipe and then works offline. `pipe export` writes
457
+ * the file; this consumes it. Connected repos are recorded as edges but cannot
458
+ * be pulled, so they are reported rather than silently skipped.
459
+ */
460
+ const pullFromFile = async (p, fromFile) => {
461
+ const { readJson } = await import("../util/fsx.js");
462
+ const { edgesFromPayload } = await import("../pipefy/discovery.js");
463
+ const raw = await readJson(resolve(fromFile));
464
+ const bare = !('payload' in raw && raw['payload']);
465
+ const env = bare
466
+ ? {
467
+ /**
468
+ * A file with no envelope gets the only usable assumption — that every
469
+ * group was read in full — because the alternative, marking them all
470
+ * unknown, makes the file undiffable and unappliable.
471
+ *
472
+ * It is an assumption and it is announced below, because if the export
473
+ * came from a reconstruction it is wrong in the dangerous direction: a
474
+ * group nobody could read looks empty, and empty means delete.
475
+ */
476
+ source: 'snapshot',
477
+ versionId: null,
478
+ readAt: new Date().toISOString(),
479
+ repoKind: 'pipe',
480
+ coverage: fullCoverage('complete'),
481
+ notes: [`read from ${fromFile}, not from the API`, 'no coverage envelope: every group assumed fully read'],
482
+ payload: raw,
483
+ }
484
+ : raw;
485
+ const repoIdNum = Number(env.payload.id);
486
+ const rootRef = {
487
+ kind: env.repoKind,
488
+ id: repoIdNum,
489
+ apiId: String(repoIdNum),
490
+ name: String(env.payload['name'] ?? repoIdNum),
491
+ uuid: env.payload['uuid'] ?? null,
492
+ via: ['file'],
493
+ depth: 0,
494
+ };
495
+ const outDir = resolve(str(p, 'out') ?? `${slugName(rootRef.name ?? String(repoIdNum))}-workspace`);
496
+ await mkdirp(outDir);
497
+ info('');
498
+ info(bold(`pipe pull --from-file ${fromFile}`));
499
+ info(dim(`read path: file (${env.source} payload) · no network`));
500
+ info('');
501
+ if (bare) {
502
+ warn(`${fromFile} is a bare payload with no coverage envelope`);
503
+ info(dim(' every entity group is assumed to have been read in full, so an absent'));
504
+ info(dim(' webhook or automation will be treated as one to delete. If this came from'));
505
+ info(dim(' `pipe export` on an endpoint without snapshots, re-export with --envelope.'));
506
+ info('');
507
+ }
508
+ if (!bool(p, 'skip-roundtrip')) {
509
+ const rt = assertRoundTrip(env);
510
+ if (rt.ok)
511
+ ok('codec round trip clean');
512
+ else {
513
+ warn('codec round trip is not clean for this payload:');
514
+ for (const d of rt.differences.slice(0, 8))
515
+ warn(` ${d.where}: ${d.detail}`);
516
+ }
517
+ }
518
+ const tree = unpack(env);
519
+ const lock = emptyLock(repoIdNum, str(p, 'org') ?? null, 0);
520
+ lock.edges = edgesFromPayload(env.payload).map((e) => ({ from: repoIdNum, ...e }));
521
+ const dir = repoDir(outDir, rootRef);
522
+ const written = await writeTree(dir, tree);
523
+ await writeJson(baselineFile(outDir, repoIdNum), env);
524
+ upsertRepo(lock, rootRef, outDir, tree);
525
+ await writeLock(outDir, lock);
526
+ await writeWorkspace(outDir, {
527
+ name: rootRef.name ?? String(repoIdNum),
528
+ createdAt: new Date().toISOString(),
529
+ rootRepoId: repoIdNum,
530
+ organizationId: str(p, 'org') ?? null,
531
+ depth: 0,
532
+ lastPullSource: env.source,
533
+ tool: { ...TOOL },
534
+ });
535
+ await writeDocs(outDir, lock, [tree], null, await loadReference(outDir));
536
+ ok(`${rootRef.kind} ${repoIdNum} ${green(rootRef.name ?? '')} — ${tree.phases.length} phases, ` +
537
+ `${tree.phases.reduce((n, ph) => n + ph.fields.length, 0)} fields, ${tree.automations.length} automations ` +
538
+ dim(`(${written.length} files)`));
539
+ const unreachable = [...new Set(lock.edges.map((e) => e.to))].filter((id) => id !== repoIdNum);
540
+ if (unreachable.length) {
541
+ info('');
542
+ warn(`this pipe connects to ${unreachable.length} other repo(s) that a file pull cannot fetch: ${unreachable.join(', ')}`);
543
+ info(dim(' export those too and pull them into their own workspaces, or pull from the API with --depth'));
544
+ }
545
+ info('');
546
+ out(bold(outDir));
547
+ info(`${yellow('next')} ${dim('cd')} ${outDir} ${dim('&&')} pipe validate`);
548
+ return 0;
549
+ };
@@ -0,0 +1,74 @@
1
+ import { join } from 'node:path';
2
+ import { promises as fs } from 'node:fs';
3
+ import { bool, str } from "../cli/args.js";
4
+ import { bold, dim, green, info, out, red, UserError, yellow } from "../util/log.js";
5
+ import { exists } from "../util/fsx.js";
6
+ import { listRuns, runDir } from "../report/run.js";
7
+ import { resolveWorkspace } from "./context.js";
8
+ export const help = `pipe runs [workspace] [options]
9
+
10
+ --show <id> print one run's report
11
+ --last print the most recent run's report
12
+ --kind <kind> only pull / apply / dry-run / verify
13
+ --failed only runs that failed or partly applied
14
+ --json machine-readable
15
+
16
+ Every run folder holds report.md (readable, no credentials), run.json, and a copy
17
+ of the pipe and its integration surface as of the end of that run.`;
18
+ export const run = async (p) => {
19
+ const root = await resolveWorkspace(p);
20
+ const runs = await listRuns(root);
21
+ const showId = str(p, 'show');
22
+ if (showId || bool(p, 'last')) {
23
+ const record = showId
24
+ ? (runs.find((r) => r.id === showId) ?? runs.find((r) => r.id.includes(showId)))
25
+ : runs[runs.length - 1];
26
+ if (!record) {
27
+ throw new UserError(showId ? `no run "${showId}" in ${root}` : `no runs recorded in ${root} yet`, 'run `pipe runs` to list what is there');
28
+ }
29
+ const path = join(runDir(root, record.id), 'report.md');
30
+ if (!(await exists(path)))
31
+ throw new UserError(`${record.id} has no report.md`);
32
+ out(await fs.readFile(path, 'utf8'));
33
+ return 0;
34
+ }
35
+ let filtered = runs;
36
+ const kind = str(p, 'kind');
37
+ if (kind)
38
+ filtered = filtered.filter((r) => r.kind === kind);
39
+ if (bool(p, 'failed'))
40
+ filtered = filtered.filter((r) => r.outcome === 'failed' || r.outcome === 'partial');
41
+ if (bool(p, 'json')) {
42
+ out(JSON.stringify(filtered, null, 2));
43
+ return 0;
44
+ }
45
+ if (!filtered.length) {
46
+ info(runs.length ? 'no runs match that filter' : `no runs recorded in ${root} yet`);
47
+ return 0;
48
+ }
49
+ out('');
50
+ out(bold(`${filtered.length} run${filtered.length === 1 ? '' : 's'} in ${root}`));
51
+ out('');
52
+ for (const r of filtered) {
53
+ out(` ${dim(r.startedAt.replace('T', ' ').slice(0, 19))} ${outcomeTag(r)} ${bold(r.kind.padEnd(8))} ${r.summary}`);
54
+ const parts = [
55
+ r.counts.steps ? `${r.counts.done}/${r.counts.steps} steps` : null,
56
+ r.counts.blocked ? red(`${r.counts.blocked} blocked`) : null,
57
+ r.verification ? (r.verification.ok ? 'verified' : red('verify failed')) : null,
58
+ `${(r.durationMs / 1000).toFixed(1)}s`,
59
+ ].filter(Boolean);
60
+ out(` ${dim(r.id)} ${dim(parts.join(' · '))}`);
61
+ }
62
+ out('');
63
+ out(dim(` pipe runs --last the newest report in full`));
64
+ out(dim(` pipe runs --show <run id> any of them`));
65
+ out('');
66
+ return 0;
67
+ };
68
+ const outcomeTag = (r) => r.outcome === 'ok'
69
+ ? green('ok ')
70
+ : r.outcome === 'partial'
71
+ ? yellow('partial ')
72
+ : r.outcome === 'failed'
73
+ ? red('failed ')
74
+ : dim(r.outcome.padEnd(8));