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