@pipefy/pipefy-process-coder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.agents/skills/ppc-pipefy-flow-authoring/SKILL.md +262 -0
  2. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-danfe-consulta.json +247 -0
  3. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-2-webhook-retorno-consulta.json +589 -0
  4. package/.agents/skills/ppc-pipefy-flow-authoring/examples/01-main-4-recebimento-barramento.json +1391 -0
  5. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-2-danfe-retorno.json +623 -0
  6. package/.agents/skills/ppc-pipefy-flow-authoring/examples/02-subflow-4-criacao-operacao.json +636 -0
  7. package/.agents/skills/ppc-pipefy-flow-authoring/examples/03-subflow-4-criacao-titulo.json +3642 -0
  8. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-cedente.json +863 -0
  9. package/.agents/skills/ppc-pipefy-flow-authoring/examples/04-subflow-4-criacao-sacado.json +799 -0
  10. package/.agents/skills/ppc-pipefy-flow-authoring/examples/README.md +42 -0
  11. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-acompanhamento-cobranca.json +581 -0
  12. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-nfe-monitoramento.json +503 -0
  13. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-01-subflow-1-titulo-retorno-bancario.json +562 -0
  14. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-cedente.json +557 -0
  15. package/.agents/skills/ppc-pipefy-flow-authoring/examples/hml-02-subflow-2-retorno-consulta-sacado.json +609 -0
  16. package/.agents/skills/ppc-pipefy-pipe-authoring/SKILL.md +146 -0
  17. package/.agents/skills/ppc-pipefy-process-design/SKILL.md +127 -0
  18. package/.agents/skills/ppc-pipefy-workspace/SKILL.md +91 -0
  19. package/AGENTS.md +456 -0
  20. package/README.md +808 -0
  21. package/bin/pipe.js +13 -0
  22. package/package.json +35 -0
  23. package/src/apply/adopt.ts +150 -0
  24. package/src/apply/agentops.ts +71 -0
  25. package/src/apply/compile.ts +875 -0
  26. package/src/apply/execute.ts +336 -0
  27. package/src/apply/flowops.ts +399 -0
  28. package/src/apply/idmap.ts +241 -0
  29. package/src/apply/mutations.ts +955 -0
  30. package/src/apply/registry.ts +430 -0
  31. package/src/apply/types.ts +134 -0
  32. package/src/cli/args.ts +88 -0
  33. package/src/cli.ts +211 -0
  34. package/src/codec/flow.ts +199 -0
  35. package/src/codec/pack.ts +103 -0
  36. package/src/codec/roundtrip.ts +94 -0
  37. package/src/codec/unpack.ts +198 -0
  38. package/src/commands/agents.ts +184 -0
  39. package/src/commands/apply.ts +1144 -0
  40. package/src/commands/context.ts +119 -0
  41. package/src/commands/create.ts +79 -0
  42. package/src/commands/diff.ts +314 -0
  43. package/src/commands/flows.ts +414 -0
  44. package/src/commands/misc.ts +644 -0
  45. package/src/commands/plan.ts +331 -0
  46. package/src/commands/pull.ts +567 -0
  47. package/src/commands/runs.ts +83 -0
  48. package/src/commands/skills.ts +137 -0
  49. package/src/commands/verify.ts +253 -0
  50. package/src/config.ts +168 -0
  51. package/src/diff/agents.ts +122 -0
  52. package/src/diff/diff.ts +1130 -0
  53. package/src/diff/flow.ts +318 -0
  54. package/src/diff/html.ts +322 -0
  55. package/src/diff/render.ts +101 -0
  56. package/src/model/payload.ts +154 -0
  57. package/src/model/tree.ts +99 -0
  58. package/src/model/volatile.ts +55 -0
  59. package/src/pipefy/agents.ts +165 -0
  60. package/src/pipefy/automations.ts +219 -0
  61. package/src/pipefy/capability.ts +119 -0
  62. package/src/pipefy/client.ts +267 -0
  63. package/src/pipefy/discovery.ts +209 -0
  64. package/src/pipefy/internal.ts +380 -0
  65. package/src/pipefy/ipaas.ts +365 -0
  66. package/src/pipefy/reconstruct.ts +775 -0
  67. package/src/pipefy/reference.ts +251 -0
  68. package/src/pipefy/snapshot.ts +245 -0
  69. package/src/pipefy/toolkit.ts +200 -0
  70. package/src/pipefy/toolkit_bearer.py +137 -0
  71. package/src/report/integrations.ts +231 -0
  72. package/src/report/run.ts +475 -0
  73. package/src/util/fsx.ts +45 -0
  74. package/src/util/git.ts +32 -0
  75. package/src/util/json.ts +55 -0
  76. package/src/util/log.ts +76 -0
  77. package/src/util/pool.ts +48 -0
  78. package/src/util/slug.ts +26 -0
  79. package/src/util/tui.ts +335 -0
  80. package/src/validate/index.ts +123 -0
  81. package/src/validate/integrity.ts +387 -0
  82. package/src/validate/reference.ts +136 -0
  83. package/src/validate/schema.ts +328 -0
  84. package/src/workspace/agents.ts +290 -0
  85. package/src/workspace/docs.ts +407 -0
  86. package/src/workspace/flows.ts +191 -0
  87. package/src/workspace/layout.ts +165 -0
  88. package/src/workspace/lock.ts +148 -0
  89. package/src/workspace/read.ts +165 -0
  90. package/src/workspace/reference.ts +24 -0
  91. package/src/workspace/stamp.ts +301 -0
  92. package/src/workspace/write.ts +225 -0
@@ -0,0 +1,1144 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ import { loadReference } from '../workspace/reference.ts';
3
+ import { bool, str, type Parsed } from '../cli/args.ts';
4
+ import { writeJson } from '../util/fsx.ts';
5
+ import { basename, resolve } from 'node:path';
6
+ import { UserError, bold, debug, dim, green, info, ok, out, red, stripAnsi, warn, yellow } from '../util/log.ts';
7
+ import { readTree } from '../workspace/read.ts';
8
+ import { writeTree } from '../workspace/write.ts';
9
+ import { unpack } from '../codec/unpack.ts';
10
+ import { packEnvelope } from '../codec/pack.ts';
11
+ import { upsertRepo, writeLock } from '../workspace/lock.ts';
12
+ import { renderStamp, stampCreated } from '../workspace/stamp.ts';
13
+ import { adoptCreatedIds, mergeAdoptedIntoBaseline, renderAdopted } from '../apply/adopt.ts';
14
+ import { baselineFile, planFile, planFileFor, findPlanFile } from '../workspace/layout.ts';
15
+ import { diffTrees } from '../diff/diff.ts';
16
+ import { compile } from '../apply/compile.ts';
17
+ import { readAutomationCatalogue } from '../pipefy/automations.ts';
18
+ import { execute } from '../apply/execute.ts';
19
+ import type { Plan, PlanOptions } from '../apply/types.ts';
20
+ import { validateTree, blockingErrors } from '../validate/index.ts';
21
+ import { connect, openWorkspace, resolveWorkspace } from './context.ts';
22
+ import { checkGit } from '../util/git.ts';
23
+ import { loadBaseline, loadBaselineEnvelope, refFor } from './diff.ts';
24
+ import { loadPlan, renderPlan, warnUnverifiedSteps } from './plan.ts';
25
+ import { verifyRepo } from './verify.ts';
26
+ import { restoreToVersion } from './misc.ts';
27
+ import { RunRecorder, invocation, type RunOutcome } from '../report/run.ts';
28
+ import {
29
+ applyFlows,
30
+ diffFlowsAgainstLive,
31
+ planFlowRevert,
32
+ planFlows,
33
+ renderFlowPlan,
34
+ reportDatapillDrift,
35
+ verifyFlowDatapills,
36
+ } from './flows.ts';
37
+ import type { FlowPlan } from '../apply/flowops.ts';
38
+ import type { FlowsSection } from '../workspace/flows.ts';
39
+ import type { AgentPlan } from '../apply/agentops.ts';
40
+ import type { AgentsSection } from '../workspace/agents.ts';
41
+ import type { Capabilities } from '../pipefy/capability.ts';
42
+
43
+ /**
44
+ * What each half of an apply did, for the *one* revert offer at the end.
45
+ *
46
+ * `pipe apply` used to offer three unrelated rollbacks — pipe structure via
47
+ * `pipe rollback`'s snapshot, flows via their own reverse plan, and nothing
48
+ * at all for agents — surfaced as separate prompts at separate moments. That
49
+ * is not how a user experiences "the apply": it is one action, and if any
50
+ * part of it fails partway, the honest question is "revert everything this
51
+ * apply changed", once, with the details of what that actually means for
52
+ * each part that touched anything. `offerUnifiedRevert` is that one prompt;
53
+ * these three shapes are what each half reports about itself so it can be
54
+ * asked.
55
+ */
56
+ type PipeHalfOutcome = {
57
+ attempted: boolean;
58
+ doneCount: number;
59
+ preApplyVersionId: string | null;
60
+ };
61
+ type FlowHalfOutcome = { attempted: boolean; failed: boolean; doneCount: number; before: FlowsSection | null };
62
+ type AgentHalfOutcome = { attempted: boolean; failed: boolean; doneCount: number; before: AgentsSection | null };
63
+
64
+ const NOT_ATTEMPTED_FLOW: FlowHalfOutcome = { attempted: false, failed: false, doneCount: 0, before: null };
65
+ const NOT_ATTEMPTED_AGENT: AgentHalfOutcome = { attempted: false, failed: false, doneCount: 0, before: null };
66
+
67
+ export const help = `pipe apply [workspace] [options]
68
+
69
+ --repo <id> apply only this repo (default: every repo in the workspace)
70
+ --exclude <id,...> apply every repo except these (id, api id, or name)
71
+ --plan <id> apply a saved plan instead of recompiling
72
+ --resume <id> resume a plan that failed partway
73
+ --yes do not ask for confirmation
74
+ --dry-run walk the plan without sending anything
75
+ --continue-on-error keep going after a failed step
76
+ --no-verify skip the post-apply comparison entirely
77
+ --post-snapshot take a fresh snapshot after applying, for a full live
78
+ read-back verify and adopt (asked about otherwise;
79
+ default no — it also spends the pre-apply rollback
80
+ snapshot's restorability, see \`pipe rollback\`)
81
+ --no-pre-snapshot do not snapshot before applying (not recommended)
82
+ --automation-recreate delete+create automations instead of updating them in place
83
+ --allow-card-moves permit phase deletes
84
+ --force apply the supported changes even if others are blocked
85
+ --report write a run report into runs/ (payload copy included)
86
+ --no-flows do not apply iPaaS flow changes
87
+ --allow-flow-enable permit enabling a flow (arms it against live traffic)`;
88
+
89
+ /**
90
+ * `pipe apply` — PLAN.md §5, steps 5 to 7.
91
+ *
92
+ * snapshot prod, labelled pre-apply/<ts> -> execute step by step, persisting
93
+ * status so a failure is resumable -> read back and diff against intent
94
+ *
95
+ * There is no draft rehearsal step: `createRepoDraft` does not resolve on this
96
+ * endpoint (PLAN.md §1.2), and the draft's relation semantics are unknown and
97
+ * potentially dangerous (§9). When it becomes available, it slots in before the
98
+ * pre-apply snapshot with no other change.
99
+ */
100
+ /**
101
+ * The flow half of an apply.
102
+ *
103
+ * A flow reads the pipe — a datapill names a field by slug, a trigger names a
104
+ * phase — so the pipe is applied first and this runs after it. Extracted so it is
105
+ * also reachable when the pipe half is clean, which is the ordinary case for a
106
+ * flow-only change; the early return was reporting "nothing to apply" over real
107
+ * pending work.
108
+ *
109
+ * Returns the exit code the caller passes through, plus what it did — the
110
+ * latter is what lets the *one* combined revert offer at the end of
111
+ * `applyOneRepo` know whether flows have anything worth reverting at all.
112
+ */
113
+ const applyFlowHalf = async (
114
+ p: Parsed,
115
+ root: string,
116
+ dir: string,
117
+ repoId: number,
118
+ net: Awaited<ReturnType<typeof connect>>,
119
+ dryRun: boolean,
120
+ ): Promise<{ exitCode: number; outcome: FlowHalfOutcome }> => {
121
+ const notAttempted = { exitCode: 0, outcome: NOT_ATTEMPTED_FLOW };
122
+ if (bool(p, 'no-flows')) return notAttempted;
123
+ const { readFlows } = await import('../workspace/flows.ts');
124
+ const authored = await readFlows(dir);
125
+ if (!authored) return notAttempted;
126
+
127
+ try {
128
+ const { changeSet: flowChanges, live } = await diffFlowsAgainstLive(net.ipaas, repoId, dir);
129
+ const flowPlan = planFlows(flowChanges, {
130
+ allowArm: bool(p, 'allow-flow-enable'),
131
+ connections: live.connections,
132
+ });
133
+
134
+ if (flowPlan.blocked.length) {
135
+ info('');
136
+ out(renderFlowPlan(flowPlan));
137
+ if (!bool(p, 'force')) {
138
+ throw new UserError(
139
+ `${flowPlan.blocked.length} flow change(s) cannot be applied`,
140
+ 'revert those edits, or re-run with --force to apply only the rest',
141
+ );
142
+ }
143
+ }
144
+
145
+ if (!flowPlan.steps.length) return notAttempted;
146
+
147
+ info('');
148
+ out(renderFlowPlan(flowPlan));
149
+ const flowResult = await applyFlows(net.ipaas, repoId, flowPlan, {
150
+ dryRun,
151
+ continueOnError: bool(p, 'continue-on-error'),
152
+ });
153
+ for (const step of flowPlan.steps) if (step.error) warn(`${step.id} ${step.description}: ${step.error}`);
154
+
155
+ if (!dryRun && flowResult.done > 0) {
156
+ /**
157
+ * The check nothing else can make. A migrating write path rewrites a
158
+ * datapill's single ['output'] into a doubled one: structurally valid,
159
+ * accepted by the platform's own validator, and resolving to nothing at
160
+ * runtime. Only comparing the stored string against the intended one finds it.
161
+ */
162
+ const drift = await verifyFlowDatapills(net.ipaas, repoId, authored);
163
+ reportDatapillDrift(drift);
164
+ if (drift.length) {
165
+ warn(
166
+ 'the flow was written but does not hold what was sent — do not enable it until this is resolved',
167
+ );
168
+ }
169
+
170
+ // Adopt: a flow id the create just minted exists only in the read-back.
171
+ const { pullAndWriteFlows } = await import('./flows.ts');
172
+ await pullAndWriteFlows(net.ipaas, repoId, dir, { workspaceRoot: root });
173
+ info(dim(' flows re-read from the pipe — new flow ids are now in the files'));
174
+ }
175
+
176
+ return {
177
+ exitCode: flowResult.failed ? 1 : 0,
178
+ outcome: { attempted: true, failed: flowResult.failed > 0, doneCount: flowResult.done, before: dryRun ? null : live },
179
+ };
180
+ } catch (e) {
181
+ if (e instanceof UserError) throw e;
182
+ warn(`flows were not applied: ${(e as Error).message}`);
183
+ return notAttempted;
184
+ }
185
+ };
186
+
187
+ /**
188
+ * Show pending flow work before the confirmation prompt asks about the pipe
189
+ * steps, so "N steps. Apply...?" is not answered blind to a flow `pipe plan`
190
+ * already showed. A saved plan file (`--plan` / `--resume`) never carried the
191
+ * flow plan at all — it is a separate, unsaved computation — so without this
192
+ * the only place a flow step is visible is inside `pipe plan`'s own output.
193
+ *
194
+ * Preview only: it changes nothing, and `applyFlowHalf` below recomputes the
195
+ * same diff for real right before sending it.
196
+ */
197
+ const previewFlowHalf = async (p: Parsed, dir: string, repoId: number, net: Awaited<ReturnType<typeof connect>>): Promise<void> => {
198
+ if (bool(p, 'no-flows')) return;
199
+ const { readFlows } = await import('../workspace/flows.ts');
200
+ if (!(await readFlows(dir))) return;
201
+ try {
202
+ const { changeSet: flowChanges, live } = await diffFlowsAgainstLive(net.ipaas, repoId, dir);
203
+ const flowPlan = planFlows(flowChanges, { allowArm: bool(p, 'allow-flow-enable'), connections: live.connections });
204
+ const rendered = renderFlowPlan(flowPlan);
205
+ if (rendered.trim()) {
206
+ info('');
207
+ out(bold('flows'));
208
+ out(rendered);
209
+ }
210
+ } catch (e) {
211
+ info(dim(` flows not previewed: ${(e as Error).message}`));
212
+ }
213
+ };
214
+
215
+ /**
216
+ * The agent half of an apply.
217
+ *
218
+ * Offline for the diff — the agent baseline is the file `pipe pull` wrote, not a
219
+ * live read — but the send itself needs the client, same as the pipe half.
220
+ * Mirrors `applyFlowHalf`: reachable when the pipe half is clean (a
221
+ * behavior-only edit is ordinary), when the pipe plan is blocked (an agent
222
+ * change should still land), and after a normal successful apply (which never
223
+ * touches agents on its own).
224
+ */
225
+ const applyAgentHalf = async (
226
+ p: Parsed,
227
+ root: string,
228
+ dir: string,
229
+ repoId: number,
230
+ repoUuid: string | null | undefined,
231
+ net: Awaited<ReturnType<typeof connect>>,
232
+ dryRun: boolean,
233
+ ): Promise<{ exitCode: number; outcome: AgentHalfOutcome }> => {
234
+ const notAttempted = { exitCode: 0, outcome: NOT_ATTEMPTED_AGENT };
235
+ if (bool(p, 'json')) return notAttempted;
236
+ try {
237
+ const { diffAgentsFor, planAgents, renderAgentPlan, applyAgentSteps, pullAndWriteAgents, pullAgentsFor } = await import(
238
+ './agents.ts'
239
+ );
240
+ const agentChanges = await diffAgentsFor(root, repoId, dir);
241
+ if (!agentChanges.changes.length) return notAttempted;
242
+
243
+ const agentPlan = await planAgents(net.client, agentChanges);
244
+ info('');
245
+ out(bold('agents'));
246
+ out(renderAgentPlan(agentPlan));
247
+
248
+ /**
249
+ * Unlike the flow half, the agent diff is offline against the local
250
+ * baseline (module doc comment, agents.ts) — nothing before this point
251
+ * has read the pipe's agents live. Without capturing that read here,
252
+ * a partial agent failure would have no rollback point at all, the same
253
+ * gap flows had before `planFlowRevert` existed.
254
+ */
255
+ const before = dryRun ? null : await pullAgentsFor(net.client, repoUuid);
256
+
257
+ const result = await applyAgentSteps(net.client, agentPlan, {
258
+ dryRun,
259
+ continueOnError: bool(p, 'continue-on-error'),
260
+ });
261
+
262
+ if (!dryRun && result.done > 0) {
263
+ /**
264
+ * Adopt: an agent/behavior id the create just minted exists only in the
265
+ * read-back. `pullAndWriteAgents` alone only rewrites `agent.json` —
266
+ * `agent.json` never carries behaviors (workspace/agents.ts) — so
267
+ * without folding the fresh read into `tree.automations` and writing
268
+ * the tree back, the `behaviors/` folders this apply just changed would
269
+ * sit stale (still showing the pre-apply ids), and the next `pipe diff`
270
+ * would see the new content as unauthored. Mirrors `pipe pull`'s own
271
+ * sequence (commands/pull.ts).
272
+ */
273
+ const section = await pullAndWriteAgents(net.client, repoId, repoUuid, dir, { workspaceRoot: root });
274
+ if (section.meta.coverage === 'complete') {
275
+ const { foldFreshBehaviors } = await import('../workspace/agents.ts');
276
+ const tree = await readTree(dir);
277
+ tree.automations = foldFreshBehaviors(tree.automations, section.agents);
278
+ await writeTree(dir, tree);
279
+ }
280
+ info(dim(' agents re-read from the pipe — new agent/behavior ids are now in the files'));
281
+ }
282
+
283
+ return {
284
+ exitCode: result.failed ? 1 : 0,
285
+ outcome: { attempted: true, failed: result.failed > 0, doneCount: result.done, before },
286
+ };
287
+ } catch (e) {
288
+ if (e instanceof UserError) throw e;
289
+ warn(`agents were not applied: ${(e as Error).message}`);
290
+ return notAttempted;
291
+ }
292
+ };
293
+
294
+ /**
295
+ * Show pending agent work before the confirmation prompt, same reason as
296
+ * `previewFlowHalf`: a saved plan file never carries the agent plan, so
297
+ * without this the only place an agent step is visible is `pipe plan`'s own
298
+ * output. Preview only — `applyAgentHalf` recomputes the diff for real.
299
+ */
300
+ const previewAgentHalf = async (
301
+ p: Parsed,
302
+ root: string,
303
+ dir: string,
304
+ repoId: number,
305
+ net: Awaited<ReturnType<typeof connect>>,
306
+ ): Promise<void> => {
307
+ if (bool(p, 'json')) return;
308
+ try {
309
+ const { diffAgentsFor, planAgents, renderAgentPlan } = await import('./agents.ts');
310
+ const agentChanges = await diffAgentsFor(root, repoId, dir);
311
+ if (!agentChanges.changes.length) return;
312
+ const agentPlan = await planAgents(net.client, agentChanges);
313
+ const rendered = renderAgentPlan(agentPlan);
314
+ if (rendered.trim()) {
315
+ info('');
316
+ out(bold('agents'));
317
+ out(rendered);
318
+ }
319
+ } catch (e) {
320
+ info(dim(` agents not previewed: ${(e as Error).message}`));
321
+ }
322
+ };
323
+
324
+ /**
325
+ * Other repos in this workspace that still have pending changes.
326
+ *
327
+ * An apply covers one repo, and a workspace holds several. Saying nothing reads
328
+ * as "the workspace is applied", which is the misreading that matters: a database
329
+ * is never the root — it is reached through a connector field or a relation — so
330
+ * a database edit always sits in a repo that some other `pipe apply` was not
331
+ * looking at. The no-op path needs this most, because "nothing to apply" is the
332
+ * most confident-sounding way to be wrong.
333
+ *
334
+ * Offline, and after the fact, so it can neither delay nor fail the apply.
335
+ */
336
+ const reportPendingElsewhere = async (
337
+ p: Parsed,
338
+ root: string,
339
+ lock: { repos: Array<{ id: number; name: string | null; apiId: string; dir: string }> },
340
+ appliedId: number,
341
+ ) => {
342
+ if (str(p, 'repo') || lock.repos.length < 2) return;
343
+ const { loadBaseline } = await import('./diff.ts');
344
+ const { diffTrees } = await import('../diff/diff.ts');
345
+ const pending: string[] = [];
346
+ for (const other of lock.repos.filter((r) => r.id !== appliedId)) {
347
+ try {
348
+ const n = diffTrees(
349
+ await loadBaseline(root, other.id),
350
+ await readTree(resolve(root, other.dir)),
351
+ { mode: 'same-repo' },
352
+ ).changes.length;
353
+ if (n) pending.push(`${other.name} — ${n} change${n === 1 ? '' : 's'} pipe apply --repo ${other.apiId}`);
354
+ } catch {
355
+ // A repo that cannot be read here is already reported by `pipe validate`.
356
+ }
357
+ }
358
+ if (!pending.length) return;
359
+ info('');
360
+ warn(`${pending.length} other repo(s) in this workspace still have pending changes:`);
361
+ for (const line of pending) info(dim(` ${line}`));
362
+ };
363
+
364
+ /**
365
+ * Local version control is the rollback path Pipefy's own snapshot cannot be:
366
+ * `pipe rollback` only restores the *latest* snapshot, and this apply's own
367
+ * read-back becomes the latest, so the pre-apply snapshot stops being
368
+ * restorable the moment the apply that took it finishes. A git commit taken
369
+ * before applying has no such expiry.
370
+ *
371
+ * Silent when a git repo already covers this workspace. Never blocks or
372
+ * fails the apply — a missing `git` is a suggestion, not a prerequisite.
373
+ */
374
+ const warnIfUnversioned = async (p: Parsed) => {
375
+ let root: string;
376
+ try {
377
+ root = await resolveWorkspace(p);
378
+ } catch {
379
+ return; // no workspace here — openWorkspace below reports the real error
380
+ }
381
+
382
+ const git = await checkGit(root);
383
+ if (git.tracked) return;
384
+
385
+ info('');
386
+ if (!git.installed) {
387
+ warn('git is not installed — this workspace has no local version history');
388
+ info(dim(' install it: https://git-scm.com/downloads'));
389
+ } else {
390
+ warn('this workspace is not a git repository — it has no local version history');
391
+ }
392
+ info(dim(' run `git init && git add -A && git commit -m "baseline"` here, and commit before each `pipe apply`'));
393
+ info(dim(' `pipe rollback` only restores the latest snapshot (see `pipe doctor`) — a local commit covers the rest'));
394
+ };
395
+
396
+ /**
397
+ * `pipe apply` — the whole workspace by default.
398
+ *
399
+ * It used to apply one repo, the root unless `--repo` said otherwise, which was
400
+ * the wrong default once databases arrived: a database can never be the root, so
401
+ * "apply" left database edits sitting there and said nothing. `pipe diff` reads
402
+ * the whole workspace, and apply is the other half of that sentence.
403
+ *
404
+ * Each repo still applies **independently** — its own plan file, its own
405
+ * pre-apply snapshot, its own read-back and verify — because they are separate
406
+ * repos with separate baselines, and a failure in one must not roll back or
407
+ * abandon another that already succeeded. So this is a loop over the real thing,
408
+ * not a merged mega-plan.
409
+ *
410
+ * `--repo` still means "only this one", and `--plan` / `--resume` stay
411
+ * single-repo because a saved plan belongs to exactly one.
412
+ */
413
+ export const run = async (p: Parsed): Promise<number> => {
414
+ await warnIfUnversioned(p);
415
+
416
+ const single = str(p, 'repo') || str(p, 'plan') || str(p, 'resume');
417
+ if (single) return applyOneRepo(p);
418
+
419
+ const { lock } = await openWorkspace(p);
420
+ const excluded = new Set(
421
+ (str(p, 'exclude') ?? '')
422
+ .split(',')
423
+ .map((x) => x.trim())
424
+ .filter(Boolean),
425
+ );
426
+ const selected = lock.repos.filter(
427
+ (r) => !excluded.has(String(r.id)) && !excluded.has(r.apiId) && !excluded.has((r.name ?? '').toLowerCase()),
428
+ );
429
+
430
+ for (const name of excluded) {
431
+ if (!lock.repos.some((r) => String(r.id) === name || r.apiId === name || (r.name ?? '').toLowerCase() === name)) {
432
+ warn(`--exclude ${name} matches no repo in this workspace`);
433
+ }
434
+ }
435
+
436
+ if (selected.length <= 1) return applyOneRepo(p);
437
+
438
+ info('');
439
+ info(
440
+ bold(`applying ${selected.length} repo(s)`) +
441
+ dim(` — ${selected.map((r) => r.name ?? r.id).join(', ')}`) +
442
+ (excluded.size ? dim(` (excluding ${[...excluded].join(', ')})`) : ''),
443
+ );
444
+
445
+ let worst = 0;
446
+ const outcomes: string[] = [];
447
+ for (const r of selected) {
448
+ info('');
449
+ info(bold(`── ${r.kind} ${r.name ?? r.id} ${dim(`(${r.apiId})`)}`));
450
+ /**
451
+ * Forcing `--repo` is what makes the inner call single-repo, and it also
452
+ * suppresses its own "other repos still have pending changes" note, which
453
+ * would be noise inside a loop that is already handling them.
454
+ */
455
+ const code = await applyOneRepo({ ...p, flags: { ...p.flags, repo: String(r.id) } });
456
+ worst = Math.max(worst, code);
457
+ outcomes.push(`${code === 0 ? 'ok' : `exit ${code}`} ${r.name ?? r.id}`);
458
+ }
459
+
460
+ info('');
461
+ out(bold('workspace'));
462
+ for (const line of outcomes) out(` ${line}`);
463
+ return worst;
464
+ };
465
+
466
+ const applyOneRepo = async (p: Parsed): Promise<number> => {
467
+ const { root, lock, repo, repoDir: dir } = await openWorkspace(p);
468
+ const dryRun = bool(p, 'dry-run');
469
+ const net = await connect(p);
470
+ const caps = await net.capabilities();
471
+
472
+ const resumeId = str(p, 'resume');
473
+ const planId = str(p, 'plan');
474
+
475
+ /**
476
+ * The run record, off unless asked for.
477
+ *
478
+ * A report folder holds a full copy of the pipe, so writing one on every apply
479
+ * would grow the workspace without bound and put webhook URLs and
480
+ * HTTP-request credentials in one more place. The record an apply *needs* is
481
+ * the plan file under `.ppc/plans/`, which is written either way and is what
482
+ * `--resume` reads. `runs/` is the optional human account of what happened.
483
+ *
484
+ * Started before anything is compiled, so that a refusal — the most
485
+ * interesting outcome to look back at, because nothing was sent and the reason
486
+ * is in the classifier — is recorded too.
487
+ */
488
+ const rec = !bool(p, 'report')
489
+ ? null
490
+ : new RunRecorder(dryRun ? 'dry-run' : 'apply', {
491
+ workspace: root,
492
+ repo: { id: repo.id, uuid: repo.uuid, name: repo.name ?? String(repo.id), kind: repo.kind },
493
+ command: invocation(),
494
+ });
495
+
496
+ let plan: Plan;
497
+ if (resumeId || planId) {
498
+ plan = await loadPlan(root, (resumeId ?? planId) as string);
499
+ if (resumeId) {
500
+ const remaining = plan.steps.filter((s) => s.status !== 'done').length;
501
+ info(`resuming plan ${plan.id} — ${remaining} of ${plan.steps.length} steps still to run`);
502
+ // A skipped step on a resume is retried: it was skipped because an id was
503
+ // missing, and the resume may now have it.
504
+ for (const s of plan.steps) if (s.status === 'skipped' || s.status === 'failed' || s.status === 'running') s.status = 'pending';
505
+ }
506
+ } else {
507
+ const edited = await readTree(dir);
508
+ const baseline = await loadBaseline(root, repo.id);
509
+
510
+ const { readAgents } = await import('../workspace/agents.ts');
511
+ const findings = validateTree(edited, {
512
+ knownRepoIds: lock.repos.map((r) => r.id),
513
+ baseline,
514
+ reference: await loadReference(root),
515
+ agents: await readAgents(dir),
516
+ });
517
+ const blocking = blockingErrors(findings);
518
+ if (blocking.length) {
519
+ throw new UserError(
520
+ `${blocking.length} validation error${blocking.length === 1 ? '' : 's'}`,
521
+ 'run `pipe validate` to see them',
522
+ );
523
+ }
524
+
525
+ rec?.before(await loadBaselineEnvelope(root, repo.id));
526
+
527
+ const changeSet = diffTrees(baseline, edited);
528
+ if (!changeSet.changes.length) {
529
+ /**
530
+ * The pipe half is clean. That is not the same as nothing to apply: flows
531
+ * are part of this loop, and a flow-only change is the ordinary case.
532
+ */
533
+ const exitCode = await finishApply(p, root, dir, repo, net, dryRun, caps, {
534
+ attempted: false,
535
+ doneCount: 0,
536
+ preApplyVersionId: null,
537
+ });
538
+ if (!exitCode) ok('nothing to apply to the pipe — the workspace matches the baseline');
539
+ await reportPendingElsewhere(p, root, lock, repo.id);
540
+ // No run folder for a no-op: one per no-op would bury the runs that changed
541
+ // something, and "nothing happened" is not worth a payload copy.
542
+ return exitCode;
543
+ }
544
+
545
+ const options: PlanOptions = {
546
+ automationStrategy: bool(p, 'automation-recreate') ? 'recreate' : 'update',
547
+ allowCardMoves: bool(p, 'allow-card-moves'),
548
+ force: bool(p, 'force'),
549
+ };
550
+ plan = await compile(changeSet, {
551
+ client: net.client,
552
+ capabilities: caps,
553
+ repoId: repo.id,
554
+ repoUuid: repo.uuid,
555
+ workspace: root,
556
+ organizationId: lock.organizationId,
557
+ /**
558
+ * The automation catalogue, so a wrong event or action parameter is refused
559
+ * here rather than discovered as "is invalid" mid-apply. Read from the public
560
+ * API; a failure to read it means the check is skipped, never that the plan is
561
+ * blocked.
562
+ */
563
+ automationCatalogue: await readAutomationCatalogue(net.client, repo.id).catch(() => undefined),
564
+ livePhaseIds: edited.phases.map((ph) => Number(ph['id'])).filter(Number.isFinite),
565
+ repoKind: repo.kind,
566
+ /** A database has one form, so its column order is that form's fields by index. */
567
+ tableFieldOrder:
568
+ repo.kind === 'table'
569
+ ? [...(edited.phases[0]?.fields ?? [])].sort((a, b) => Number(a['index'] ?? 0) - Number(b['index'] ?? 0))
570
+ : undefined,
571
+ baselineTableFieldOrder:
572
+ repo.kind === 'table'
573
+ ? [...(baseline?.phases[0]?.fields ?? [])]
574
+ .sort((a, b) => Number(a['index'] ?? 0) - Number(b['index'] ?? 0))
575
+ .map((f) => String(f['slug'] ?? ''))
576
+ : undefined,
577
+ fieldUuidById: Object.fromEntries(
578
+ edited.phases
579
+ .flatMap((ph) => ph.fields)
580
+ .filter((fl) => fl['id'] && fl['uuid'])
581
+ .map((fl) => [String(fl['id']), String(fl['uuid'])]),
582
+ ),
583
+ /** For `updateTable.title_field_id`, which refuses the numeric id. */
584
+ fieldSlugById: Object.fromEntries(
585
+ edited.phases
586
+ .flatMap((ph) => ph.fields)
587
+ .filter((fl) => fl['id'] && fl['slug'])
588
+ .map((fl) => [String(fl['id']), String(fl['slug'])]),
589
+ ),
590
+ startFormPhaseId:
591
+ (edited.phases.find((ph) => Number(ph['index'] ?? -1) === 0)?.['id'] as number | undefined) ?? null,
592
+ conditionOrder: Object.fromEntries(
593
+ [...new Set(edited.field_conditions.map((fc) => String(fc['phase_id'] ?? '')))]
594
+ .filter((phaseId) => phaseId !== '')
595
+ .map((phaseId) => [
596
+ phaseId,
597
+ edited.field_conditions
598
+ .filter((fc) => String(fc['phase_id'] ?? '') === phaseId)
599
+ .slice()
600
+ .sort((a, b) => Number(a['index'] ?? 0) - Number(b['index'] ?? 0))
601
+ .map((fc) => ({ name: String(fc['name'] ?? ''), id: fc['id'] ? Number(fc['id']) : null })),
602
+ ]),
603
+ ),
604
+ baselineConditionOrder: Object.fromEntries(
605
+ [...new Set((baseline?.field_conditions ?? []).map((fc) => String(fc['phase_id'] ?? '')))]
606
+ .filter((phaseId) => phaseId !== '')
607
+ .map((phaseId) => [
608
+ phaseId,
609
+ (baseline?.field_conditions ?? [])
610
+ .filter((fc) => String(fc['phase_id'] ?? '') === phaseId)
611
+ .slice()
612
+ .sort((a, b) => Number(a['index'] ?? 0) - Number(b['index'] ?? 0))
613
+ .map((fc) => String(fc['id'])),
614
+ ]),
615
+ ),
616
+ }, options);
617
+ }
618
+
619
+ out(renderPlan(plan));
620
+ info('');
621
+ rec?.fromPlan(plan);
622
+
623
+ if (plan.blocked.length && !plan.options.force) {
624
+ const message = `${plan.blocked.length} change${plan.blocked.length === 1 ? '' : 's'} in this workspace cannot be applied`;
625
+ const exitCode = await finishApply(p, root, dir, repo, net, dryRun, caps, {
626
+ attempted: false,
627
+ doneCount: 0,
628
+ preApplyVersionId: null,
629
+ });
630
+ if (exitCode) return exitCode;
631
+ await reportRun(rec, 'refused', message);
632
+ throw new UserError(message, 'revert those edits, or re-run with --force to apply only the supported ones');
633
+ }
634
+
635
+ warnUnverifiedSteps(plan);
636
+
637
+ await previewFlowHalf(p, dir, repo.id, net);
638
+ await previewAgentHalf(p, root, dir, repo.id, net);
639
+
640
+ const destructive = plan.steps.filter((s) => s.destructive);
641
+ if (!dryRun && !bool(p, 'yes')) {
642
+ const confirmed = await confirm(
643
+ destructive.length
644
+ ? `${plan.steps.length} steps, ${red(`${destructive.length} destructive`)}. Apply to pipe ${repo.id} (${repo.name})?`
645
+ : `${plan.steps.length} steps. Apply to pipe ${repo.id} (${repo.name})?`,
646
+ );
647
+ if (!confirmed) {
648
+ info('aborted');
649
+ return 130;
650
+ }
651
+ }
652
+
653
+ // ── pre-apply snapshot: the rollback point ────────────────────────────────
654
+ if (!dryRun && caps.snapshots && !bool(p, 'no-pre-snapshot')) {
655
+ const label = `pre-apply/${new Date().toISOString()}`;
656
+ try {
657
+ const versionId = await net.snapshots.create(repo.id);
658
+ await net.snapshots.waitFor(repo.id, versionId);
659
+ await net.snapshots.rename(repo.id, versionId, label);
660
+ plan.preApplyVersionId = versionId;
661
+ rec?.note(`pre-apply snapshot ${versionId} labelled ${label}`);
662
+ ok(`pre-apply snapshot ${versionId.slice(0, 8)} labelled ${label}`);
663
+ /**
664
+ * What this snapshot is actually worth, stated honestly.
665
+ *
666
+ * A restore mutation does exist here — `restoreRepoToSnapshot` — but it
667
+ * only accepts the **latest** uploaded snapshot, and the read-back this
668
+ * command takes to verify becomes the latest. So the pre-apply snapshot is
669
+ * a rollback point right up until the apply finishes, and a record after
670
+ * that. Saying "no one-command rollback", as this used to, was wrong; so
671
+ * would be implying it can undo a completed apply.
672
+ */
673
+ if (!caps.restoreRepoSnapshot) {
674
+ warn('no restore mutation on this endpoint — this snapshot is a record, not a rollback point');
675
+ } else if (caps.restoreLatestOnly) {
676
+ info(
677
+ dim(
678
+ ` restorable with \`pipe rollback --repo ${repo.id} --version ${versionId}\` until something ` +
679
+ 'snapshots this repo again — including the read-back at the end of this apply',
680
+ ),
681
+ );
682
+ }
683
+ } catch (e) {
684
+ warn(`could not take a pre-apply snapshot: ${(e as Error).message}`);
685
+ const go = bool(p, 'yes') || (await confirm('Continue without a rollback point?'));
686
+ if (!go) return 130;
687
+ }
688
+ } else if (!dryRun && !caps.snapshots) {
689
+ warn('this endpoint has no snapshots, so there is no rollback point. A failed apply is repaired forward.');
690
+ }
691
+
692
+ /**
693
+ * Resuming writes back to the file that already exists, under whatever name it
694
+ * was left with, instead of starting a second copy under the bare id.
695
+ */
696
+ const path = (await findPlanFile(root, plan.id)) ?? planFile(root, plan.id);
697
+ await writeJson(path, plan);
698
+
699
+ const result = await execute(plan, {
700
+ client: net.client,
701
+ internal: net.internal,
702
+ planPath: path,
703
+ dryRun,
704
+ continueOnError: bool(p, 'continue-on-error'),
705
+ });
706
+
707
+ /**
708
+ * Stamp the outcome into the filename, so `.ppc/plans/` says what happened
709
+ * without opening anything. Renamed only after the executor has stopped
710
+ * writing to the file, and never on a dry run, which sent nothing.
711
+ */
712
+ let planPath = path;
713
+ if (!dryRun) {
714
+ const settled = planFileFor(root, plan.id, plan.status);
715
+ if (settled !== path) {
716
+ try {
717
+ const { rename } = await import('node:fs/promises');
718
+ await rename(path, settled);
719
+ planPath = settled;
720
+ } catch (e) {
721
+ debug(`could not rename the plan file: ${(e as Error).message}`);
722
+ }
723
+ }
724
+ }
725
+
726
+ // The executor's own summary line already reported the counts.
727
+
728
+ /**
729
+ * Adopting a created entity's id no longer needs a live read-back at all:
730
+ * the id came back on the create mutation's own response, already sitting
731
+ * in `result.idMap` (idmap.ts), which is exactly where `%{_new:<uuid>}`
732
+ * substitution for *later* steps in this same run got it from. This runs
733
+ * whether the apply fully succeeded, partially failed, or was resumed from
734
+ * a saved plan — creates before the failure need the same adoption a fully
735
+ * successful run does, and unconditionally is simpler than two copies of
736
+ * the same logic split across the branches below.
737
+ *
738
+ * `edited`/`changeSet` from the compile branch above are not reused,
739
+ * because `--resume`/`--plan` never populate them at all. Re-reading and
740
+ * re-diffing here instead is the one code path both cases share: diffing
741
+ * the same (unedited-since-compile) tree against the same baseline
742
+ * reproduces the same change keys, so `changeByKey` still finds the row a
743
+ * step's `changeKey` refers to.
744
+ */
745
+ let adopted: ReturnType<typeof adoptCreatedIds> = [];
746
+ if (!dryRun && result.done > 0) {
747
+ try {
748
+ const finalTree = await readTree(dir);
749
+ const previousEnvelope = await loadBaselineEnvelope(root, repo.id);
750
+ const previousTree = previousEnvelope ? unpack(previousEnvelope) : null;
751
+ // No baseline at all means this repo was never pulled properly — the
752
+ // compile branch above would already have refused before execute() ran.
753
+ if (previousTree) {
754
+ const finalChangeSet = diffTrees(previousTree, finalTree);
755
+ adopted = adoptCreatedIds(finalChangeSet, plan, result.idMap);
756
+
757
+ if (adopted.length) {
758
+ const patched = result.idMap.rewrite(finalTree) as typeof finalTree;
759
+ await writeTree(dir, patched);
760
+ const changedBaseline = mergeAdoptedIntoBaseline(previousTree, patched, adopted);
761
+ if (changedBaseline) await writeJson(baselineFile(root, repo.id), packEnvelope(previousTree));
762
+ for (const line of renderAdopted(adopted)) {
763
+ info(line);
764
+ rec?.note(line);
765
+ }
766
+ info(dim(' no read-back needed for this — the id came from the create response itself'));
767
+ }
768
+ }
769
+ } catch (e) {
770
+ warn(`could not adopt created ids: ${(e as Error).message}`);
771
+ rec?.warn(`could not adopt created ids: ${(e as Error).message}`);
772
+ info(dim(' re-pull before re-planning, or the next apply may create them again'));
773
+ }
774
+ }
775
+
776
+ if (result.failed) {
777
+ info('');
778
+ warn(`plan ${plan.id} is ${plan.status}`);
779
+
780
+ /**
781
+ * Pipe-only: a pipe-side failure means flows and agents were never
782
+ * attempted in this run (they read the pipe, so it goes first) — nothing
783
+ * else to fold into the offer.
784
+ */
785
+ await offerUnifiedRevert(
786
+ p,
787
+ net,
788
+ repo,
789
+ dir,
790
+ root,
791
+ {
792
+ pipe: { attempted: true, doneCount: result.done, preApplyVersionId: plan.preApplyVersionId ?? null },
793
+ flow: NOT_ATTEMPTED_FLOW,
794
+ agent: NOT_ATTEMPTED_AGENT,
795
+ },
796
+ caps,
797
+ );
798
+
799
+ await reportRun(
800
+ rec,
801
+ result.done > 0 ? 'partial' : 'failed',
802
+ `${result.done} of ${plan.steps.length} steps applied, ${result.failed} failed` +
803
+ `${result.skipped ? `, ${result.skipped} skipped` : ''}`,
804
+ );
805
+
806
+ info('');
807
+ info(dim(` the record of this attempt is .ppc/plans/${basename(planPath)}`));
808
+ info(`${yellow('resume')} pipe apply --resume ${plan.id} ${dim('replays the saved requests as they were compiled')}`);
809
+ info(`${yellow('re-plan')} pipe apply ${dim('recompiles — use this after changing pipe itself')}`);
810
+ if (plan.preApplyVersionId && caps.restoreRepoSnapshot) {
811
+ info(`${yellow('roll back')} pipe rollback --version ${plan.preApplyVersionId} --repo ${repo.id} ${dim('pipe structure only — not flows, not anything already sent or fired')}`);
812
+ }
813
+ return 1;
814
+ }
815
+
816
+ if (!dryRun && !bool(p, 'no-verify')) {
817
+ info('');
818
+ /**
819
+ * A fresh snapshot is never taken here on its own initiative — that is
820
+ * exactly what makes the pre-apply rollback point stop being restorable
821
+ * (see the comment where it was taken, and `pipe rollback`'s own). Ids are
822
+ * already adopted above without one, so what is being asked for here is
823
+ * strictly extra: a full live comparison, and a wholesale adopt of
824
+ * whatever the server filled in that the create response did not carry
825
+ * (slug, color_uuid, and the like — never anything the author has an
826
+ * opinion about; see AGENTS.md "verify against intent, not identity").
827
+ */
828
+ const takeSnapshot = caps.snapshots && (bool(p, 'post-snapshot') || (await confirmPostSnapshot(p)));
829
+ const verified = await verifyRepo({
830
+ net,
831
+ root,
832
+ lock,
833
+ repo,
834
+ expectedTreeDir: dir,
835
+ idMap: result.idMap,
836
+ preferReconstruct: !takeSnapshot,
837
+ });
838
+ if (!verified.ok) {
839
+ warn(
840
+ takeSnapshot
841
+ ? 'verification found differences between what was applied and what the workspace asks for:'
842
+ : 'a free, GraphQL-reconstructed comparison found differences — coverage gaps may hide more (see below):',
843
+ );
844
+ out(verified.render);
845
+
846
+ /**
847
+ * `result.failed` is false to have reached this branch — every step
848
+ * reported success. A mismatch here does not mean nothing happened; it
849
+ * means verify's comparison could not recognise what did. That is exactly
850
+ * what a server-minted uuid causes: the entity this apply just created
851
+ * reads as "delete the live one, create the authored one" instead of
852
+ * "these are the same thing", because the tree still names it by the
853
+ * uuid the author made up, not the one the server actually assigned —
854
+ * `adoptCreatedIds` above already fixed the *id*, but not the uuid the
855
+ * server may have minted independently of it.
856
+ *
857
+ * Only worth running against a real read: reconciling against a
858
+ * GraphQL reconstruction's partial coverage risks the same "adopt an
859
+ * absence as a deletion" mistake the coverage doctrine exists to avoid.
860
+ */
861
+ if (takeSnapshot) {
862
+ const authored = await readTree(dir);
863
+ const previous = await loadBaselineEnvelope(root, repo.id);
864
+ const stamp = stampCreated(authored, unpack(verified.envelope), previous ? unpack(previous) : null);
865
+ if (stamp.changed) {
866
+ await writeTree(dir, authored);
867
+ for (const line of renderStamp(stamp)) {
868
+ info(line);
869
+ rec?.note(stripAnsi(line).trim());
870
+ }
871
+ }
872
+ await writeJson(baselineFile(root, repo.id), verified.envelope);
873
+ info(
874
+ dim(
875
+ stamp.changed
876
+ ? ' baseline updated from the read-back; ids for what was created are now in the files too'
877
+ : ' baseline updated from the read-back; the files are otherwise untouched, so `--resume` still works',
878
+ ),
879
+ );
880
+ } else {
881
+ info(dim(' not adopting from this read — it may be missing groups the create responses already fixed for real'));
882
+ }
883
+ rec?.after(verified.envelope);
884
+ rec?.verified(false, 'the applied pipe does not satisfy the workspace intent');
885
+ await reportRun(rec, 'partial', `all ${result.done} steps applied, but verification found differences`);
886
+ return 1;
887
+ }
888
+ ok(takeSnapshot ? 'verified — the pipe now matches the workspace' : 'verified against a free GraphQL reconstruction — the pipe matches the workspace');
889
+ rec?.after(verified.envelope);
890
+ rec?.verified(true);
891
+
892
+ if (takeSnapshot) {
893
+ /**
894
+ * Adopt the live state as both the workspace and the baseline —
895
+ * strictly more complete than the id-only adoption above, since it also
896
+ * picks up whatever the server filled in on its own (slug, color_uuid,
897
+ * the connector flags). Only reachable here, on a real read: adopting a
898
+ * reconstruction wholesale would discard whatever group its coverage
899
+ * cannot see, which the id-only adoption above never risks.
900
+ */
901
+ const live = unpack(verified.envelope);
902
+ /**
903
+ * A live read-back has no idea which automations are agent behaviors — that
904
+ * only exists in the `agents/` folders already on disk. Without re-tagging
905
+ * here, this write would move every behavior back into `automations/` on
906
+ * every single successful apply.
907
+ */
908
+ const { tagBehaviors, behaviorDirMapOnDisk } = await import('../workspace/agents.ts');
909
+ live.automations = tagBehaviors(live.automations, await behaviorDirMapOnDisk(dir));
910
+ await writeTree(dir, live);
911
+ await writeJson(baselineFile(root, repo.id), verified.envelope);
912
+ upsertRepo(lock, refFor(repo), root, live);
913
+ await writeLock(root, lock);
914
+ info(dim(' workspace and baseline updated from the applied state — server-assigned ids are now in the files'));
915
+ }
916
+ info(dim(` applied plan recorded as .ppc/plans/${basename(planPath)}`));
917
+
918
+ await reportPendingElsewhere(p, root, lock, repo.id);
919
+ }
920
+
921
+ await reportRun(
922
+ rec,
923
+ 'ok',
924
+ dryRun
925
+ ? `${plan.steps.length} steps simulated, nothing sent`
926
+ : `${result.done} steps applied${bool(p, 'no-verify') ? ', not verified' : ' and verified'}`,
927
+ );
928
+
929
+ /**
930
+ * The pipe half succeeded, and that is not the only thing this apply owes:
931
+ * the other two call sites cover "nothing to apply to the pipe" and "the
932
+ * pipe plan is blocked", but a normal, non-blocked pipe change reached this
933
+ * point without ever touching flows. `pipe plan` computes and prints the
934
+ * flow plan on its own, so it looked applied — it was never sent.
935
+ *
936
+ * The pipe outcome passed here is what lets a *later* flow or agent
937
+ * failure still offer to undo the pipe changes that landed in this same
938
+ * run — they succeeded, but "this apply" as a whole did not.
939
+ */
940
+ return finishApply(p, root, dir, repo, net, dryRun, caps, {
941
+ attempted: true,
942
+ doneCount: result.done,
943
+ preApplyVersionId: plan.preApplyVersionId ?? null,
944
+ });
945
+ };
946
+
947
+ /**
948
+ * Write the run folder and say where it went.
949
+ *
950
+ * Every exit from `pipe apply` that did something goes through here — success,
951
+ * partial, refusal — because the run whose report is missing is always the one
952
+ * that went wrong.
953
+ */
954
+ const reportRun = async (rec: RunRecorder | null, outcome: RunOutcome, summary: string) => {
955
+ if (!rec) return;
956
+ const res = await rec.write(outcome, summary);
957
+ if (!res) return;
958
+ if (res.error) {
959
+ warn(`could not write the run report: ${res.error}`);
960
+ return;
961
+ }
962
+ info(dim(` run recorded in runs/${rec.id}/`));
963
+ };
964
+
965
+ const confirm = async (question: string): Promise<boolean> => {
966
+ if (!process.stdin.isTTY) {
967
+ warn('not a tty — pass --yes to apply non-interactively');
968
+ return false;
969
+ }
970
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
971
+ try {
972
+ const answer = await rl.question(`${question} [y/N] `);
973
+ return /^y(es)?$/i.test(answer.trim());
974
+ } finally {
975
+ rl.close();
976
+ }
977
+ };
978
+
979
+ /**
980
+ * Whether to spend a fresh snapshot on a full live verify/adopt.
981
+ *
982
+ * Never the default, and never asked at all non-interactively: `confirm`
983
+ * already declines on a non-tty, and `--yes` means "proceed without asking",
984
+ * which for something this endpoint cannot be asked to undo means declining,
985
+ * not accepting silently.
986
+ */
987
+ const confirmPostSnapshot = async (p: Parsed): Promise<boolean> => {
988
+ if (bool(p, 'yes')) return false;
989
+ return confirm('Create a fresh snapshot for a full live verify and adopt? (spends the pre-apply rollback point)');
990
+ };
991
+
992
+ /**
993
+ * Run flows, then agents if flows did not fail, and — if anything across
994
+ * pipe, flow or agent failed — ask the *one* combined revert question.
995
+ * Every exit from `applyOneRepo` that could still touch flows or agents
996
+ * goes through this, so there is exactly one place that decides whether a
997
+ * revert is worth offering, no matter which half actually failed.
998
+ */
999
+ const finishApply = async (
1000
+ p: Parsed,
1001
+ root: string,
1002
+ dir: string,
1003
+ repo: { id: number; uuid: string | null | undefined },
1004
+ net: Awaited<ReturnType<typeof connect>>,
1005
+ dryRun: boolean,
1006
+ caps: Capabilities,
1007
+ pipeOutcome: PipeHalfOutcome,
1008
+ ): Promise<number> => {
1009
+ const flowResult = await applyFlowHalf(p, root, dir, repo.id, net, dryRun);
1010
+ const agentResult = flowResult.outcome.failed
1011
+ ? { exitCode: 0, outcome: NOT_ATTEMPTED_AGENT }
1012
+ : await applyAgentHalf(p, root, dir, repo.id, repo.uuid, net, dryRun);
1013
+
1014
+ const anyFailed = flowResult.outcome.failed || agentResult.outcome.failed;
1015
+ if (!dryRun && anyFailed) {
1016
+ await offerUnifiedRevert(p, net, repo, dir, root, { pipe: pipeOutcome, flow: flowResult.outcome, agent: agentResult.outcome }, caps);
1017
+ }
1018
+ return flowResult.exitCode || agentResult.exitCode ? 1 : 0;
1019
+ };
1020
+
1021
+ /**
1022
+ * The one revert question `pipe apply` ever asks. Offered whenever pipe,
1023
+ * flow or agent failed partway, and — deliberately — whenever any of the
1024
+ * *other* two succeeded fully during the same run: "this apply" is one
1025
+ * action from where the user sits, so a failure anywhere in it means the
1026
+ * honest question is "revert everything it changed", not three separate
1027
+ * prompts scoped to whichever half happened to error.
1028
+ *
1029
+ * Each half gets reverted the way its own platform actually allows,
1030
+ * because there is no such thing as one API that reverses all three:
1031
+ *
1032
+ * - **Pipe structure** — `restoreRepoToSnapshot`, Pipefy's own mechanism,
1033
+ * restoring the pre-apply snapshot. Only offered while that snapshot is
1034
+ * still the latest one (`caps.restoreLatestOnly`) — nothing before this
1035
+ * point takes a second snapshot on its own initiative, so it normally
1036
+ * still is.
1037
+ * - **iPaaS flows** and **AI agents** have no snapshot at all. Each is
1038
+ * reverted by diffing the *current* live state against the read taken
1039
+ * right before this apply touched it, and sending that reverse plan
1040
+ * through the same compiler/executor an ordinary apply uses
1041
+ * (`planFlowRevert`, `planAgentRevert`) — built for exactly this,
1042
+ * verified live (see AGENTS.md).
1043
+ *
1044
+ * Rendered in full, for every part that has anything to revert, before the
1045
+ * one question is asked. Reverted in the reverse of the order they were
1046
+ * applied — agents, then flows, then pipe structure — on confirmation.
1047
+ * Declining leaves everything exactly as this apply left it.
1048
+ *
1049
+ * Still true, and said so explicitly if nothing here can help: no revert,
1050
+ * Pipefy's or this tool's, un-sends an email or un-fires a webhook an
1051
+ * automation already triggered. That is the platform's own irreversibility.
1052
+ */
1053
+ const offerUnifiedRevert = async (
1054
+ p: Parsed,
1055
+ net: Awaited<ReturnType<typeof connect>>,
1056
+ repo: { id: number; uuid: string | null | undefined },
1057
+ dir: string,
1058
+ root: string,
1059
+ outcome: { pipe: PipeHalfOutcome; flow: FlowHalfOutcome; agent: AgentHalfOutcome },
1060
+ caps: Capabilities,
1061
+ ): Promise<void> => {
1062
+ const parts: string[] = [];
1063
+
1064
+ let pipeRestorable = false;
1065
+ if (outcome.pipe.attempted && outcome.pipe.doneCount > 0 && outcome.pipe.preApplyVersionId && caps.restoreRepoSnapshot) {
1066
+ if (caps.restoreLatestOnly) {
1067
+ const latest = (await net.snapshots.list(repo.id, 1))[0];
1068
+ pipeRestorable = !latest || latest.versionId === outcome.pipe.preApplyVersionId;
1069
+ if (!pipeRestorable) {
1070
+ info(dim(' the pre-apply snapshot is no longer the latest, so pipe structure cannot be rolled back automatically'));
1071
+ }
1072
+ } else {
1073
+ pipeRestorable = true;
1074
+ }
1075
+ if (pipeRestorable) parts.push(`pipe structure (${outcome.pipe.doneCount} step(s), via the pre-apply snapshot)`);
1076
+ }
1077
+
1078
+ let flowRevertPlan: FlowPlan | null = null;
1079
+ if (outcome.flow.attempted && outcome.flow.before && outcome.flow.doneCount > 0) {
1080
+ const { plan } = await planFlowRevert(net.ipaas, repo.id, outcome.flow.before);
1081
+ if (plan.steps.length) {
1082
+ flowRevertPlan = plan;
1083
+ parts.push(`iPaaS flows (${plan.steps.length} step(s))`);
1084
+ }
1085
+ }
1086
+
1087
+ let agentRevertPlan: AgentPlan | null = null;
1088
+ if (outcome.agent.attempted && outcome.agent.before && outcome.agent.doneCount > 0) {
1089
+ const { planAgentRevert } = await import('./agents.ts');
1090
+ const { plan } = await planAgentRevert(net.client, repo.uuid, outcome.agent.before);
1091
+ if (plan.steps.length) {
1092
+ agentRevertPlan = plan;
1093
+ parts.push(`AI agents (${plan.steps.length} step(s))`);
1094
+ }
1095
+ }
1096
+
1097
+ if (!parts.length) return;
1098
+
1099
+ info('');
1100
+ warn('this apply did not fully succeed');
1101
+ out(bold('reverting would do this:'));
1102
+ if (pipeRestorable) out(` pipe structure — restore the pre-apply snapshot`);
1103
+ if (flowRevertPlan) out(renderFlowPlan(flowRevertPlan));
1104
+ if (agentRevertPlan) {
1105
+ const { renderAgentPlan } = await import('./agents.ts');
1106
+ out(renderAgentPlan(agentRevertPlan));
1107
+ }
1108
+ info(
1109
+ dim(
1110
+ ' no revert, Pipefy\'s or this tool\'s, un-sends an email or un-fires a webhook an automation already triggered',
1111
+ ),
1112
+ );
1113
+
1114
+ const wantsRevert = bool(p, 'yes') ? false : await confirm(`Revert everything this apply changed — ${parts.join(', ')}?`);
1115
+ if (!wantsRevert) return;
1116
+
1117
+ if (agentRevertPlan) {
1118
+ const { applyAgentSteps, pullAndWriteAgents } = await import('./agents.ts');
1119
+ const r = await applyAgentSteps(net.client, agentRevertPlan, { dryRun: false, continueOnError: true });
1120
+ for (const step of agentRevertPlan.steps) if (step.error) warn(`${step.id} ${step.description}: ${step.error}`);
1121
+ ok(r.failed ? `agent revert partially failed — ${r.done} of ${agentRevertPlan.steps.length} step(s) landed` : 'agents reverted to the pre-apply state');
1122
+ const section = await pullAndWriteAgents(net.client, repo.id, repo.uuid, dir, { workspaceRoot: root });
1123
+ if (section.meta.coverage === 'complete') {
1124
+ const { foldFreshBehaviors } = await import('../workspace/agents.ts');
1125
+ const tree = await readTree(dir);
1126
+ tree.automations = foldFreshBehaviors(tree.automations, section.agents);
1127
+ await writeTree(dir, tree);
1128
+ }
1129
+ }
1130
+
1131
+ if (flowRevertPlan) {
1132
+ const r = await applyFlows(net.ipaas, repo.id, flowRevertPlan, { dryRun: false, continueOnError: true });
1133
+ for (const step of flowRevertPlan.steps) if (step.error) warn(`${step.id} ${step.description}: ${step.error}`);
1134
+ ok(r.failed ? `flow revert partially failed — ${r.done} of ${flowRevertPlan.steps.length} step(s) landed` : 'flow(s) reverted to the pre-apply state');
1135
+ const { pullAndWriteFlows } = await import('./flows.ts');
1136
+ await pullAndWriteFlows(net.ipaas, repo.id, dir, { workspaceRoot: root });
1137
+ }
1138
+
1139
+ if (pipeRestorable) {
1140
+ await restoreToVersion(net, repo.id, outcome.pipe.preApplyVersionId as string, caps);
1141
+ }
1142
+
1143
+ info(dim(' re-read from the pipe after the revert — files and baseline reflect what actually landed'));
1144
+ };