in2-agent-server 0.20260821.185429
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/README.md +369 -0
- package/agents/plan-requirement.md +32 -0
- package/dist/campfire/configItem.js +201 -0
- package/dist/campfire/configItemDescription.js +379 -0
- package/dist/campfire/deriveFromBundle.js +1013 -0
- package/dist/cli.js +465 -0
- package/dist/core/betaFeedback.js +493 -0
- package/dist/core/chapterInstructions.js +473 -0
- package/dist/core/claudeAssets.js +61 -0
- package/dist/core/gate.js +201 -0
- package/dist/core/ids.js +9 -0
- package/dist/core/install.js +60 -0
- package/dist/core/operatorQa.js +462 -0
- package/dist/core/orgCallMeter.js +178 -0
- package/dist/core/parcels.js +1235 -0
- package/dist/core/paths.js +105 -0
- package/dist/core/phases.js +225 -0
- package/dist/core/planFile.js +251 -0
- package/dist/core/planFragments.js +119 -0
- package/dist/core/readsLog.js +398 -0
- package/dist/core/registration.js +24 -0
- package/dist/core/result.js +38 -0
- package/dist/core/resume.js +233 -0
- package/dist/core/revisionChain.js +351 -0
- package/dist/core/richText.js +61 -0
- package/dist/core/semanticHash.js +89 -0
- package/dist/core/sessionOrg.js +60 -0
- package/dist/core/sfQuery.js +19 -0
- package/dist/core/spawn.js +182 -0
- package/dist/core/supervisorContract.js +230 -0
- package/dist/core/telemetry.js +1297 -0
- package/dist/core/telemetryContext.js +154 -0
- package/dist/core/telemetrySpool.js +244 -0
- package/dist/core/transcriptUpload.js +516 -0
- package/dist/core/update.js +363 -0
- package/dist/core/viewer.js +117 -0
- package/dist/core/workspace.js +92 -0
- package/dist/core/writeGrants.js +93 -0
- package/dist/core/writebackManifest.js +92 -0
- package/dist/direct/applyLoop.js +2516 -0
- package/dist/direct/batchReport.js +510 -0
- package/dist/direct/comparatorNoise.js +75 -0
- package/dist/direct/containers.js +85 -0
- package/dist/direct/contamination.js +221 -0
- package/dist/direct/crud.js +56 -0
- package/dist/direct/destructive.js +168 -0
- package/dist/direct/knownTokens.js +94 -0
- package/dist/direct/lint.js +382 -0
- package/dist/direct/metadataSchema.generated.json +1 -0
- package/dist/direct/metadataSchema.js +179 -0
- package/dist/direct/operatorPage.js +349 -0
- package/dist/direct/orgRepo.js +1531 -0
- package/dist/direct/orgRepoSpawnCount.js +21 -0
- package/dist/direct/plannedScope.js +119 -0
- package/dist/direct/preImageTargets.js +63 -0
- package/dist/direct/refVerify.js +191 -0
- package/dist/direct/registry.js +129 -0
- package/dist/direct/removals.js +262 -0
- package/dist/direct/rollback.js +1513 -0
- package/dist/direct/rollbackRun.js +1360 -0
- package/dist/direct/sessionProjection.js +126 -0
- package/dist/direct/snapshotComparability.js +165 -0
- package/dist/direct/sourceTree.js +113 -0
- package/dist/direct/unstatedChanges.js +106 -0
- package/dist/direct/waveAssembly.js +556 -0
- package/dist/direct/waveCounts.js +19 -0
- package/dist/direct/waveDerivation.js +152 -0
- package/dist/direct/waveGrade.js +185 -0
- package/dist/index.js +90 -0
- package/dist/orgread/customFieldSnapshot.js +48 -0
- package/dist/orgread/fieldMetadata.js +178 -0
- package/dist/orgread/genericSnapshot.js +61 -0
- package/dist/orgread/layoutSnapshot.js +151 -0
- package/dist/orgread/salesforceOrgReader.js +184 -0
- package/dist/orgread/snapshotTypes.js +9 -0
- package/dist/projection/bridge.js +26 -0
- package/dist/projection/diff.js +141 -0
- package/dist/projection/mapmd.js +213 -0
- package/dist/projection/nameAmbiguity.js +106 -0
- package/dist/projection/precedent.js +136 -0
- package/dist/projection/project.js +214 -0
- package/dist/projection/scope.js +110 -0
- package/dist/projection/types.js +5 -0
- package/dist/tools/analyze.js +322 -0
- package/dist/tools/auth.js +915 -0
- package/dist/tools/campfire.js +1742 -0
- package/dist/tools/closeBatch.js +154 -0
- package/dist/tools/closeText.js +35 -0
- package/dist/tools/direct.js +1613 -0
- package/dist/tools/feedback.js +318 -0
- package/dist/tools/operator.js +287 -0
- package/dist/tools/org.js +739 -0
- package/dist/tools/phase.js +249 -0
- package/dist/tools/projection.js +172 -0
- package/dist/tools/session.js +313 -0
- package/dist/tools/update.js +82 -0
- package/dist/tools/waves.js +793 -0
- package/package.json +48 -0
- package/reference/campfire-configuration-items-guide.md +710 -0
- package/reference/deployability-cribs.md +119 -0
- package/reference/operator-voice.md +141 -0
- package/reference/org-read-cribs.md +34 -0
- package/reference/scoped-dml-interim.md +68 -0
- package/reference/supervisor-playbook.md +313 -0
- package/reference/templates.md +233 -0
- package/skills/in2-delivery/SKILL.md +322 -0
- package/skills/in2-delivery/references/campfire-configuration-items-guide.md +710 -0
- package/skills/in2-delivery/references/deployability-cribs.md +119 -0
- package/skills/in2-delivery/references/operator-voice.md +141 -0
- package/skills/in2-delivery/references/org-read-cribs.md +34 -0
- package/skills/in2-delivery/references/scoped-dml-interim.md +68 -0
- package/skills/in2-delivery/references/templates.md +233 -0
- package/skills/plan-requirement/SKILL.md +188 -0
- package/viewer/index.html +899 -0
- package/viewer/server.mjs +433 -0
|
@@ -0,0 +1,793 @@
|
|
|
1
|
+
// plan_waves — derive the deploy order from the analyzers' own dependency data.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NOT JUST A CONVENIENCE
|
|
4
|
+
//
|
|
5
|
+
// direct_apply is 44.6% of all time spent inside tools, and almost all of that is the ~45s checkOnly
|
|
6
|
+
// every wave pays. CF76 ran SEVEN waves over 25 components. Wave count is therefore the single
|
|
7
|
+
// biggest remaining lever on run time, and it was being set by hand each run, from a topo sort the
|
|
8
|
+
// supervisor did in its head over fifteen separate plan.json files.
|
|
9
|
+
//
|
|
10
|
+
// Every input needed is already recorded: each analyzer writes the components it OWNS and, per
|
|
11
|
+
// component, the `dependsOn` keys that must exist first. A dependency that is not owned by anyone in
|
|
12
|
+
// this batch is already in the org, so it constrains nothing.
|
|
13
|
+
//
|
|
14
|
+
// CYCLES ARE REAL HERE AND MUST NOT SIMPLY REFUSE. Run against CF76's actual plans, the very first
|
|
15
|
+
// thing this found was CustomObject:CF76_Intake_Request__c in a cycle: the object carries
|
|
16
|
+
// compactLayoutAssignment so it depends on the compact layout, and the compact layout depends on the
|
|
17
|
+
// object. That is not bad data. It is a genuine Salesforce shape, and it is resolved by deploying
|
|
18
|
+
// the object TWICE — once plain, once carrying the assignment. CF76's supervisor worked that out
|
|
19
|
+
// unaided and then hit the assembler's "authored twice" refusal for it.
|
|
20
|
+
//
|
|
21
|
+
// A COMPONENT MAY HAVE TWO STATES IN ONE BATCH, and that is what stops the false cycles.
|
|
22
|
+
//
|
|
23
|
+
// The remediation above used to end "build the late wave by hand", because nothing could say which
|
|
24
|
+
// of a component's two files belonged in which wave. It can now: plan.json declares an ordered chain
|
|
25
|
+
// of revisions per component (`intent` + `authoredFile`), `core/revisionChain.ts` derives and orders
|
|
26
|
+
// it, and the predecessor edge is injected into THIS graph before leveling — so a create in one
|
|
27
|
+
// requirement and an update in another is an ordinary two-wave edge. Measured cost of not having it
|
|
28
|
+
// (EV-31…40, 2026-08-02): ten such pairs reported as ten "cycles", 23 of 53 components unplaceable,
|
|
29
|
+
// and an operator hand-copying files into wave trees, which bypasses exactly the safety wave
|
|
30
|
+
// assembly exists to provide.
|
|
31
|
+
//
|
|
32
|
+
// Late-facet stripping and cycle reporting SURVIVE as diagnostics — the shapes are still real, and
|
|
33
|
+
// the planner still cannot tell which state of a component belongs where. What changed is the
|
|
34
|
+
// remediation: declare the second state as a second entry, and ordering plus assembly follow from
|
|
35
|
+
// the plan. REPORT-ONLY, deliberately: this tool never writes the split it names. Emitting plan.json
|
|
36
|
+
// entries would make the planner an author, and the one thing it cannot know is what the second
|
|
37
|
+
// state should CONTAIN.
|
|
38
|
+
//
|
|
39
|
+
// INTENT IS CROSS-CHECKED HERE, BEFORE ANYTHING TOUCHES THE ORG (R7). The check is grounded in this
|
|
40
|
+
// session's own projection and NOTHING ELSE — local disk, no org round-trip — and it is three-valued
|
|
41
|
+
// on purpose: see `projectionExistence`.
|
|
42
|
+
//
|
|
43
|
+
// DELETIONS ARE ORDERED HERE AND EXECUTED NOWHERE. A component's last state can be `intent: "delete"`,
|
|
44
|
+
// which carries no authored file and so can never be a wave member. What it does carry is a POSITION,
|
|
45
|
+
// and until this landed that position existed only in an analyzer's prose: the plan that forced this
|
|
46
|
+
// change had to write "the destructive step's position relative to the other waves is the supervisor's
|
|
47
|
+
// to sequence by hand" (REQ-15747). `placeDeletions` derives it from the same wave levels everything
|
|
48
|
+
// else is ordered by. The report then hands back the members in direct_apply's `deletes` shape and
|
|
49
|
+
// says plainly that nothing has been deleted — declaring a deletion orders it; executing it stays an
|
|
50
|
+
// explicit tool call, because a destructive package rolls back whole and destruction is a decision.
|
|
51
|
+
import path from "node:path";
|
|
52
|
+
import { promises as fs } from "node:fs";
|
|
53
|
+
import { z } from "zod";
|
|
54
|
+
import { fail, internalError, ok } from "../core/result.js";
|
|
55
|
+
import { WorkspaceError, sessionDir } from "../core/workspace.js";
|
|
56
|
+
import { checkRevisionBindings, readPlanFile } from "../core/planFile.js";
|
|
57
|
+
import { auditPlanFragments, renderFragmentAudit } from "../core/planFragments.js";
|
|
58
|
+
import { deriveRevisionChains } from "../core/revisionChain.js";
|
|
59
|
+
import { collectSessionProjection, describeSources } from "../direct/sessionProjection.js";
|
|
60
|
+
import { writeWaveDerivation } from "../direct/waveDerivation.js";
|
|
61
|
+
import { plannedConfigurationItems, writePlannedScope } from "../direct/plannedScope.js";
|
|
62
|
+
import { resolveAuthored } from "../direct/waveAssembly.js";
|
|
63
|
+
import { resolveSessionOrg } from "../core/sessionOrg.js";
|
|
64
|
+
import { loadRequirementsIndex, loadSession } from "./session.js";
|
|
65
|
+
/**
|
|
66
|
+
* A parent depending on its own child — `CustomObject:X` needing `CompactLayout:X.Y`.
|
|
67
|
+
*
|
|
68
|
+
* On a component's FIRST state this is never a real ordering constraint, it is a LATE FACET: the
|
|
69
|
+
* parent deploys first without that element, the child follows, and the parent is re-deployed
|
|
70
|
+
* carrying it. Left in the graph it is a cycle, and one such edge stranded 22 of CF76's 26
|
|
71
|
+
* components as unorderable — technically correct and completely useless to act on.
|
|
72
|
+
*
|
|
73
|
+
* On a LATER REVISION the same edge is the whole point of the split and is honoured, not dropped —
|
|
74
|
+
* see the guard at the drop site for why that provably cannot cycle.
|
|
75
|
+
*
|
|
76
|
+
* Recognised structurally rather than by a type list: the dependency's fullName is the dependent's
|
|
77
|
+
* fullName plus a dot and more. That is precisely the source-format parent/child relationship, and
|
|
78
|
+
* it does not need maintaining as Salesforce adds types.
|
|
79
|
+
*/
|
|
80
|
+
function isLateFacetEdge(fromFullName, depKey) {
|
|
81
|
+
const depFullName = depKey.slice(depKey.indexOf(":") + 1);
|
|
82
|
+
return depFullName.startsWith(`${fromFullName}.`);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* A fullName that is an analyzer's placeholder rather than a real API name.
|
|
86
|
+
*
|
|
87
|
+
* TYPE-AWARE, because "contains a space" is not the placeholder signal it looks like: a Layout
|
|
88
|
+
* fullName is `Object-Layout Label` and the label half legitimately carries spaces, and folder
|
|
89
|
+
* types carry `/` and `$` (unfiled$public/My_Template). The original blanket [A-Za-z0-9_.\-]
|
|
90
|
+
* check excluded every real Layout from ordering and told the supervisor to "resolve the
|
|
91
|
+
* placeholder" — an instruction that, followed, corrupts a correct plan.json (live-caught CF79;
|
|
92
|
+
* the layout was hand-placed into the last wave instead).
|
|
93
|
+
*/
|
|
94
|
+
export function isPlaceholderFullName(typeName, fullName) {
|
|
95
|
+
if (/[<>]/.test(fullName))
|
|
96
|
+
return true; // live: `IN78_Test_Console__c.<UNNAMED_PICKLIST — …>`
|
|
97
|
+
if (typeName === "Layout")
|
|
98
|
+
return !/^[A-Za-z0-9_]+-[^<>]+$/.test(fullName);
|
|
99
|
+
return !/^[A-Za-z0-9_.$/\-]+$/.test(fullName);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Longest-path level assignment, with cycles reported rather than thrown.
|
|
103
|
+
*
|
|
104
|
+
* A component's wave is one past the deepest thing it depends on, so the wave count equals the
|
|
105
|
+
* longest dependency chain — the fewest waves the batch can be delivered in. Components in a cycle
|
|
106
|
+
* get no level; they are returned separately for the caller to break.
|
|
107
|
+
*/
|
|
108
|
+
export function planWaves(owned) {
|
|
109
|
+
// Dedupe FIRST, and say so. A component listed twice in one plan is the double-authoring shape
|
|
110
|
+
// assembly refuses on, and counting the entries rather than the components made this report
|
|
111
|
+
// claim "25 of 26 ordered" — a phantom missing component that would send someone hunting for it.
|
|
112
|
+
//
|
|
113
|
+
// Deduped by NODE, not by key: two revisions of one component are two nodes and are not a
|
|
114
|
+
// duplicate. Rows that carry no `node` fall back to the key, so a caller that knows nothing about
|
|
115
|
+
// revisions gets exactly today's behavior.
|
|
116
|
+
const byNode = new Map();
|
|
117
|
+
const duplicates = [];
|
|
118
|
+
for (const o of owned) {
|
|
119
|
+
const id = o.node ?? o.key;
|
|
120
|
+
const seen = byNode.get(id);
|
|
121
|
+
if (seen) {
|
|
122
|
+
duplicates.push(`${o.key} listed twice by ${seen.extId === o.extId ? o.extId : `${seen.extId} and ${o.extId}`}`);
|
|
123
|
+
// Union the dependencies; either listing may carry an edge the other lacks.
|
|
124
|
+
seen.dependsOn = [...new Set([...seen.dependsOn, ...o.dependsOn])];
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
byNode.set(id, { ...o, dependsOn: [...o.dependsOn] });
|
|
128
|
+
}
|
|
129
|
+
const unique = [...byNode.values()];
|
|
130
|
+
const ownedKeys = new Set(unique.map((o) => o.key));
|
|
131
|
+
const nodeOf = (o) => o.node ?? o.key;
|
|
132
|
+
// A dependency names a COMPONENT, and it is satisfied by that component's FIRST state — "X must
|
|
133
|
+
// exist first" is answered by the create, not by every later state of it. Resolving dependents
|
|
134
|
+
// onto the last revision instead would both over-constrain the batch and, wherever the chain's own
|
|
135
|
+
// edges close the loop, manufacture the very cycle the revision model exists to dissolve.
|
|
136
|
+
const rootNode = new Map();
|
|
137
|
+
for (const o of unique)
|
|
138
|
+
if (!o.predecessor && !rootNode.has(o.key))
|
|
139
|
+
rootNode.set(o.key, nodeOf(o));
|
|
140
|
+
for (const o of unique)
|
|
141
|
+
if (!rootNode.has(o.key))
|
|
142
|
+
rootNode.set(o.key, nodeOf(o));
|
|
143
|
+
const lateFacets = [];
|
|
144
|
+
const inBatch = new Map();
|
|
145
|
+
for (const o of unique) {
|
|
146
|
+
const id = nodeOf(o);
|
|
147
|
+
const deps = new Set();
|
|
148
|
+
for (const d of o.dependsOn) {
|
|
149
|
+
const target = rootNode.get(d);
|
|
150
|
+
if (target === undefined)
|
|
151
|
+
continue; // nothing here owns it — it is already in the org
|
|
152
|
+
// An entry depending on its OWN key reads "after the previous state of me" (the same reading
|
|
153
|
+
// core/revisionChain.ts gives it). On a later revision that IS the chain edge and resolves to
|
|
154
|
+
// the predecessor; on the first state there is no previous state, and treating it as an edge
|
|
155
|
+
// is precisely how a one-node "cycle" gets manufactured — ten of them on the EV run.
|
|
156
|
+
if (target === id)
|
|
157
|
+
continue;
|
|
158
|
+
// The late-facet drop applies ONLY to a component's first state, where honouring the edge is
|
|
159
|
+
// the CF76 cycle. On a later revision it is honoured: dependencies resolve through rootNode
|
|
160
|
+
// to FIRST states, so nothing can ever depend on a non-root revision — the only edges into
|
|
161
|
+
// one are chain edges from still-later revisions, which point backwards — and an out-edge
|
|
162
|
+
// from a non-root node therefore cannot close a cycle. Before this guard the drop was
|
|
163
|
+
// unconditional (it predates the revision model), so the split produced a revision that
|
|
164
|
+
// DEPLOYED but never ORDERED: live-caught on CF81, where the object re-emitted to carry
|
|
165
|
+
// compactLayoutAssignment sat in wave 2 while its CompactLayout was built in wave 3, and the
|
|
166
|
+
// operator placed the revision in wave 4 by hand. This guard puts it there itself.
|
|
167
|
+
if (o.predecessor === undefined && isLateFacetEdge(o.fullName, d)) {
|
|
168
|
+
lateFacets.push({ component: o.key, waitsFor: d });
|
|
169
|
+
continue; // first state: ordered as if independent; the re-deploy is reported instead
|
|
170
|
+
}
|
|
171
|
+
deps.add(target);
|
|
172
|
+
}
|
|
173
|
+
// The chain edge, injected BEFORE leveling: a create and its update are two waves apart by
|
|
174
|
+
// construction. That also keeps the assembler's invariant — one revision per key per wave —
|
|
175
|
+
// true without the assembler having to enforce an ordering it cannot see.
|
|
176
|
+
if (o.predecessor !== undefined && byNode.has(o.predecessor))
|
|
177
|
+
deps.add(o.predecessor);
|
|
178
|
+
inBatch.set(id, [...deps]);
|
|
179
|
+
}
|
|
180
|
+
const level = new Map();
|
|
181
|
+
const cycles = [];
|
|
182
|
+
const state = new Map();
|
|
183
|
+
const labelOf = (id) => {
|
|
184
|
+
const o = byNode.get(id);
|
|
185
|
+
if (!o)
|
|
186
|
+
return id;
|
|
187
|
+
return o.path ? `${o.key} (${o.path})` : o.key;
|
|
188
|
+
};
|
|
189
|
+
const visit = (id, stack) => {
|
|
190
|
+
const known = level.get(id);
|
|
191
|
+
if (known !== undefined)
|
|
192
|
+
return known;
|
|
193
|
+
if (state.get(id) === "visiting") {
|
|
194
|
+
// Record the cycle exactly once, from where it closes.
|
|
195
|
+
const at = stack.indexOf(id);
|
|
196
|
+
if (at >= 0) {
|
|
197
|
+
const ring = stack.slice(at);
|
|
198
|
+
const seen = ring.slice().sort().join("|");
|
|
199
|
+
if (!cycles.some((c) => c.slice().sort().join("|") === seen))
|
|
200
|
+
cycles.push(ring.map(labelOf));
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
state.set(id, "visiting");
|
|
205
|
+
let deepest = -1;
|
|
206
|
+
let broken = false;
|
|
207
|
+
for (const dep of inBatch.get(id) ?? []) {
|
|
208
|
+
const d = visit(dep, [...stack, id]);
|
|
209
|
+
if (d === null)
|
|
210
|
+
broken = true;
|
|
211
|
+
else
|
|
212
|
+
deepest = Math.max(deepest, d);
|
|
213
|
+
}
|
|
214
|
+
state.set(id, "done");
|
|
215
|
+
if (broken)
|
|
216
|
+
return null;
|
|
217
|
+
const assigned = deepest + 1;
|
|
218
|
+
level.set(id, assigned);
|
|
219
|
+
return assigned;
|
|
220
|
+
};
|
|
221
|
+
for (const o of unique)
|
|
222
|
+
visit(nodeOf(o), []);
|
|
223
|
+
const depth = level.size > 0 ? Math.max(...level.values()) + 1 : 0;
|
|
224
|
+
const waves = Array.from({ length: depth }, () => []);
|
|
225
|
+
for (const [id, lv] of level) {
|
|
226
|
+
const o = byNode.get(id);
|
|
227
|
+
waves[lv].push({ typeName: o.typeName, fullName: o.fullName, ...(o.path ? { path: o.path } : {}) });
|
|
228
|
+
}
|
|
229
|
+
// Stable order inside a wave so two runs of the same batch produce the same plan.
|
|
230
|
+
for (const w of waves)
|
|
231
|
+
w.sort((a, b) => `${a.typeName}:${a.fullName}`.localeCompare(`${b.typeName}:${b.fullName}`));
|
|
232
|
+
const unowned = [...new Set(unique.flatMap((o) => o.dependsOn.filter((d) => !ownedKeys.has(d))))].sort();
|
|
233
|
+
return { waves, cycles, lateFacets, duplicates, unowned, waveOf: level, rootNode };
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Place each declared deletion AFTER the waves that touch its component.
|
|
237
|
+
*
|
|
238
|
+
* A deletion is not a wave member — it has no authored file and deploys through `direct_apply`'s
|
|
239
|
+
* `deletes` argument, an mdapi destructive package rather than a source deploy. What it does have is
|
|
240
|
+
* a POSITION, and until now that position lived only in an analyzer's prose: the live plan this
|
|
241
|
+
* model comes from had to write "the destructive step's position relative to the other waves is the
|
|
242
|
+
* supervisor's to sequence by hand" (REQ-15747, assumption A3). It is derivable, so it is derived.
|
|
243
|
+
*
|
|
244
|
+
* The constraint has two sources, and both are just wave levels the planner already computed:
|
|
245
|
+
* the chain predecessor (the batch's own create/update of the same component — a deletion never
|
|
246
|
+
* precedes the wave that builds what it deletes) and the deletion's `dependsOn` keys, resolved onto
|
|
247
|
+
* each component's FIRST state exactly as every other dependent is (`planWaves`' `rootNode`).
|
|
248
|
+
*
|
|
249
|
+
* ORDERING ONLY. Nothing here deletes anything, and nothing downstream reads this as permission to.
|
|
250
|
+
*/
|
|
251
|
+
export function placeDeletions(plan, deletions) {
|
|
252
|
+
return deletions.map((d) => {
|
|
253
|
+
const because = [];
|
|
254
|
+
let after = -1;
|
|
255
|
+
let unplaceable = false;
|
|
256
|
+
const consider = (node, label) => {
|
|
257
|
+
if (node === undefined)
|
|
258
|
+
return;
|
|
259
|
+
const level = plan.waveOf.get(node);
|
|
260
|
+
if (level === undefined) {
|
|
261
|
+
// The thing it waits on never got a wave — it is in a cycle. Placing the deletion anyway
|
|
262
|
+
// would order it against a wave that does not exist.
|
|
263
|
+
unplaceable = true;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (level + 1 > after) {
|
|
267
|
+
after = level + 1;
|
|
268
|
+
because.length = 0;
|
|
269
|
+
}
|
|
270
|
+
if (level + 1 === after)
|
|
271
|
+
because.push(label);
|
|
272
|
+
};
|
|
273
|
+
consider(d.predecessorNode, `this batch ${d.chainRoot === "create" ? "creates" : "changes"} ${d.key}`);
|
|
274
|
+
for (const dep of d.dependsOn) {
|
|
275
|
+
if (dep === d.key)
|
|
276
|
+
continue; // its own chain edge, already carried by predecessorNode
|
|
277
|
+
consider(plan.rootNode.get(dep), `depends on ${dep}`);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
key: d.key,
|
|
281
|
+
typeName: d.typeName,
|
|
282
|
+
fullName: d.fullName,
|
|
283
|
+
extId: d.extId,
|
|
284
|
+
afterWave: after > 0 ? after : null,
|
|
285
|
+
because,
|
|
286
|
+
createdInBatch: d.chainRoot === "create",
|
|
287
|
+
unplaceable
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* THE EXISTENCE PREDICATE, three-valued, from this session's own projection and nothing else.
|
|
293
|
+
*
|
|
294
|
+
* `present` and `absent` are both EVIDENCE and are read as such: they drive intent inference, the
|
|
295
|
+
* create-on-existing hazard, and the update-without-root refusal. `unknown` drives none of them.
|
|
296
|
+
*
|
|
297
|
+
* The line between `absent` and `unknown` is the whole point, and it is drawn where the projection
|
|
298
|
+
* can actually demonstrate coverage: a component the projection ASKED THE ORG ABOUT and got
|
|
299
|
+
* `found: false` for is absent; a component no projection in this session ever named is unknown.
|
|
300
|
+
* `collectSessionProjection` is narrower than the batch by construction
|
|
301
|
+
* (`direct/sessionProjection.ts:12-19`) — it is a point-in-time read of a scope the supervisor
|
|
302
|
+
* chose — so non-observation is not observation of absence, and manufacturing `absent` from it
|
|
303
|
+
* would ground a refusal (`UPDATE_WITHOUT_ROOT`) on components that demonstrably exist.
|
|
304
|
+
*
|
|
305
|
+
* The view's `objects` set is deliberately NOT read as coverage. It records which objects some
|
|
306
|
+
* projection enumerated, but the view drops each enumeration's `errors[]`, so "the object was in
|
|
307
|
+
* scope" cannot establish "and this field would have been seen if it existed". Inferring absence
|
|
308
|
+
* from it would be exactly the manufactured coverage this predicate exists to avoid.
|
|
309
|
+
*/
|
|
310
|
+
export function projectionExistence(view) {
|
|
311
|
+
return ({ key }) => {
|
|
312
|
+
const observed = view.components.get(key);
|
|
313
|
+
if (!observed)
|
|
314
|
+
return "unknown";
|
|
315
|
+
return observed.found ? "present" : "absent";
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const revisionNode = (entry) => `${entry.key}#${entry.extId}#${entry.index}`;
|
|
319
|
+
/**
|
|
320
|
+
* Plan entries → wave-graph rows, via the revision chains.
|
|
321
|
+
*
|
|
322
|
+
* Pure over the entries plus the predicate, so the EV corpus regression runs against the same
|
|
323
|
+
* transcription `core/revisionChain.test.ts` asserts on, with no session on disk.
|
|
324
|
+
*
|
|
325
|
+
* `priorRefusals` are the per-file findings `parsePlanFile` already made (an illegal `intent`, a
|
|
326
|
+
* within-file duplicate). They are merged rather than re-derived, and their keys are excluded from
|
|
327
|
+
* planning for the same reason the chain module's own refusals are: an entry whose declared intent
|
|
328
|
+
* is not a legal value has not told anyone where it belongs.
|
|
329
|
+
*/
|
|
330
|
+
export function planRevisions(entries, exists, priorRefusals = []) {
|
|
331
|
+
// R3 over the WHOLE BATCH. The shape this model exists for puts a component's two revisions in
|
|
332
|
+
// two different plan.json files, where each file's own check sees one entry per key and finds
|
|
333
|
+
// nothing wrong (`core/planFile.ts:311-320`).
|
|
334
|
+
const bindingRefusals = checkRevisionBindings(entries);
|
|
335
|
+
const refusals = [];
|
|
336
|
+
const seenRefusal = new Set();
|
|
337
|
+
const addRefusal = (r) => {
|
|
338
|
+
const at = `${r.code}|${r.key}`;
|
|
339
|
+
if (seenRefusal.has(at))
|
|
340
|
+
return;
|
|
341
|
+
seenRefusal.add(at);
|
|
342
|
+
refusals.push(r);
|
|
343
|
+
};
|
|
344
|
+
for (const r of bindingRefusals)
|
|
345
|
+
addRefusal({ code: r.code, key: r.key, message: r.message });
|
|
346
|
+
for (const r of priorRefusals)
|
|
347
|
+
addRefusal({ code: r.code, key: r.key, message: r.message });
|
|
348
|
+
const blocked = new Set(refusals.map((r) => r.key));
|
|
349
|
+
// Only the DEPLOYABLE entries of a blocked key are withheld; a route sharing that key still
|
|
350
|
+
// reports as a route, because dropping it would make the batch look smaller than it is.
|
|
351
|
+
const chainable = entries.filter((e) => !(e.deployable && blocked.has(e.key)));
|
|
352
|
+
const derived = deriveRevisionChains(chainable, exists);
|
|
353
|
+
for (const r of derived.refusals)
|
|
354
|
+
addRefusal({ code: r.code, key: r.key, message: r.message });
|
|
355
|
+
const owned = [];
|
|
356
|
+
const deletions = [];
|
|
357
|
+
const createOnExisting = [];
|
|
358
|
+
for (const chain of derived.chains) {
|
|
359
|
+
// The DEPLOYED states are the wave rows. A terminal delete is lifted out first, so a
|
|
360
|
+
// create-then-delete chain reads as a single-revision key here — one wave row with no `path`,
|
|
361
|
+
// exactly today's shape — and the deletion travels as an ordering constraint instead.
|
|
362
|
+
const states = chain.revisions.filter((rev) => rev.intent !== "delete");
|
|
363
|
+
// A single-revision key keeps `key` as its node and carries no `path`, so the wave entry shape
|
|
364
|
+
// is exactly today's for every batch that declares one state per component. Additive, per R12.
|
|
365
|
+
const multi = states.length > 1;
|
|
366
|
+
let previous;
|
|
367
|
+
for (const rev of states) {
|
|
368
|
+
const node = multi ? revisionNode(rev.entry) : chain.key;
|
|
369
|
+
owned.push({
|
|
370
|
+
key: chain.key,
|
|
371
|
+
typeName: chain.typeName,
|
|
372
|
+
fullName: chain.fullName,
|
|
373
|
+
extId: rev.entry.extId,
|
|
374
|
+
dependsOn: [...rev.entry.dependsOn],
|
|
375
|
+
node,
|
|
376
|
+
...(multi && rev.entry.authoredFile ? { path: rev.entry.authoredFile } : {}),
|
|
377
|
+
...(previous !== undefined ? { predecessor: previous } : {})
|
|
378
|
+
});
|
|
379
|
+
previous = node;
|
|
380
|
+
}
|
|
381
|
+
for (const rev of chain.revisions) {
|
|
382
|
+
if (rev.intent !== "delete")
|
|
383
|
+
continue;
|
|
384
|
+
deletions.push({
|
|
385
|
+
key: chain.key,
|
|
386
|
+
typeName: chain.typeName,
|
|
387
|
+
fullName: chain.fullName,
|
|
388
|
+
extId: rev.entry.extId,
|
|
389
|
+
index: rev.entry.index,
|
|
390
|
+
...(previous !== undefined ? { predecessorNode: previous } : {}),
|
|
391
|
+
dependsOn: [...rev.entry.dependsOn],
|
|
392
|
+
chainRoot: chain.root
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
const root = states[0];
|
|
396
|
+
if (chain.root === "create" && chain.existence === "present" && root) {
|
|
397
|
+
createOnExisting.push({ key: chain.key, extId: root.entry.extId, index: root.entry.index, intentSource: root.intentSource });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
// A key whose intent could not be VERIFIED still orders exactly as it does today, as long as it
|
|
401
|
+
// has one state. What `INTENT_UNVERIFIABLE` leaves open is create-vs-update, and with a single
|
|
402
|
+
// entry that question changes nothing about the graph — there is no second state to sequence it
|
|
403
|
+
// against. Withholding it instead would make an unprojected session plan zero waves, which is
|
|
404
|
+
// every session before its first project_org (R12: existing plans work on day one).
|
|
405
|
+
const chained = new Set(derived.chains.map((c) => c.key));
|
|
406
|
+
const refused = new Set(refusals.map((r) => r.key));
|
|
407
|
+
const byKey = new Map();
|
|
408
|
+
for (const e of chainable) {
|
|
409
|
+
// A delete is excluded from the fallback for the same reason it is excluded from the chains'
|
|
410
|
+
// wave rows: there is no file to deploy. A key whose ONLY entry is a delete therefore lands in
|
|
411
|
+
// neither `owned` nor here, which is correct — it is a deletion, not an unordered component.
|
|
412
|
+
if (!e.deployable || e.intent === "delete")
|
|
413
|
+
continue;
|
|
414
|
+
byKey.set(e.key, [...(byKey.get(e.key) ?? []), e]);
|
|
415
|
+
}
|
|
416
|
+
for (const [key, group] of byKey) {
|
|
417
|
+
if (chained.has(key) || refused.has(key))
|
|
418
|
+
continue;
|
|
419
|
+
const only = group.length === 1 ? group[0] : undefined;
|
|
420
|
+
if (!only)
|
|
421
|
+
continue;
|
|
422
|
+
owned.push({ key, typeName: only.typeName, fullName: only.fullName, extId: only.extId, dependsOn: [...only.dependsOn] });
|
|
423
|
+
}
|
|
424
|
+
const placed = new Set([...owned.map((o) => o.key), ...deletions.map((d) => d.key)]);
|
|
425
|
+
// A deletion is PLACED even though it is not in a wave — `placeDeletions` gives it a position.
|
|
426
|
+
// Counting it as unplaced would report the batch as failing to order a component whose whole
|
|
427
|
+
// point is that it stops existing, and send the supervisor to fix a plan that is correct.
|
|
428
|
+
const unplaced = [...new Set(entries.filter((e) => e.deployable && e.intent !== "delete" && !placed.has(e.key)).map((e) => e.key))].sort();
|
|
429
|
+
return { owned, inferences: derived.inferences, hazards: derived.hazards, refusals, createOnExisting, routes: derived.routes, deletions, unplaced };
|
|
430
|
+
}
|
|
431
|
+
const indent = (text, prefix = " ") => text.split("\n").map((line) => `${prefix}${line}`);
|
|
432
|
+
/**
|
|
433
|
+
* Plan entries plus the XML they were authored as — the input the planned COUNT is taken over.
|
|
434
|
+
*
|
|
435
|
+
* The XML is read for one reason: container expansion. A Workflow file is zero Workflow
|
|
436
|
+
* configurations and N WorkflowAlert ones, and the delivered count expands it that way from the
|
|
437
|
+
* evidence bundle. Counting the container here and its children there is precisely the unit drift
|
|
438
|
+
* QA round 1 item 24 was (205 components reported as 94 configurations), so the denominator reads
|
|
439
|
+
* the same bytes the numerator will.
|
|
440
|
+
*
|
|
441
|
+
* BEST-EFFORT PER FILE, never per batch: an entry whose file is missing or unreadable counts once
|
|
442
|
+
* at its own grain. That is the honest floor — an unread container may hide children, so the count
|
|
443
|
+
* can be low, and a low denominator makes the rate CONSERVATIVE rather than flattering. Refusing
|
|
444
|
+
* the whole scope over one unreadable file would throw away a good denominator to protect against
|
|
445
|
+
* a slightly small one.
|
|
446
|
+
*/
|
|
447
|
+
async function plannedInputs(sessionRoot, entries) {
|
|
448
|
+
return Promise.all(entries.map(async (entry) => {
|
|
449
|
+
const base = { typeName: entry.typeName, fullName: entry.fullName, extId: entry.extId };
|
|
450
|
+
if (!entry.authoredFile)
|
|
451
|
+
return base;
|
|
452
|
+
const file = resolveAuthored(path.join(sessionRoot, "requirements", entry.extId), entry.authoredFile);
|
|
453
|
+
if (!file)
|
|
454
|
+
return base;
|
|
455
|
+
try {
|
|
456
|
+
return { ...base, xml: await fs.readFile(file, "utf8") };
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
return base;
|
|
460
|
+
}
|
|
461
|
+
}));
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* The report. Extracted from the handler so its REMEDIATIONS are testable — they are the part of
|
|
465
|
+
* this tool a supervisor acts on, and the two that used to end "build the late wave by hand" are
|
|
466
|
+
* exactly the prose the revision model retires.
|
|
467
|
+
*/
|
|
468
|
+
export function renderWavePlan(input) {
|
|
469
|
+
const { plan, planning } = input;
|
|
470
|
+
const { waves, cycles, lateFacets, duplicates, unowned } = plan;
|
|
471
|
+
const placedCounts = new Map();
|
|
472
|
+
for (const c of waves.flat()) {
|
|
473
|
+
const at = `${c.typeName}:${c.fullName}`;
|
|
474
|
+
placedCounts.set(at, (placedCounts.get(at) ?? 0) + 1);
|
|
475
|
+
}
|
|
476
|
+
const multiState = [...placedCounts.values()].filter((n) => n > 1).length;
|
|
477
|
+
const lines = [
|
|
478
|
+
`${placedCounts.size} of ${input.distinct} component(s) ordered into ${waves.length} wave(s).`,
|
|
479
|
+
...(multiState > 0
|
|
480
|
+
? [`${multiState} of them deploy MORE THAN ONCE — each later state is an entry of its own in the plan, so no wave is built by hand.`]
|
|
481
|
+
: []),
|
|
482
|
+
...waves.flatMap((w, i) => [
|
|
483
|
+
` w${i + 1} (${w.length})`,
|
|
484
|
+
...w.map((c) => ` ${c.typeName}:${c.fullName}${c.path ? ` <- ${c.path}` : ""}`)
|
|
485
|
+
]),
|
|
486
|
+
...(planning.inferences.length > 0
|
|
487
|
+
? [
|
|
488
|
+
"",
|
|
489
|
+
`${planning.inferences.length} entr(y/ies) declared no intent; inferred from this session's projection (${input.grounding}):`,
|
|
490
|
+
...planning.inferences.map((i) => ` ${i.key} (${i.extId} entry ${i.index}) -> ${i.intent}, because the projection found it ${i.from}`)
|
|
491
|
+
]
|
|
492
|
+
: []),
|
|
493
|
+
...(input.deletions.length > 0
|
|
494
|
+
? [
|
|
495
|
+
"",
|
|
496
|
+
`${input.deletions.length} DELETION(S) declared. These are NOT waves and this tool has not deleted anything —`,
|
|
497
|
+
"a deletion has no authored file, so it deploys as a destructive package rather than a source wave.",
|
|
498
|
+
"Where each one sits, derived from what this batch does to the same component:",
|
|
499
|
+
...input.deletions.map((d) => {
|
|
500
|
+
const where = d.unplaceable
|
|
501
|
+
? "position UNRESOLVED — the work it must follow is stuck in a cycle above; fix the cycle first"
|
|
502
|
+
: d.afterWave === null
|
|
503
|
+
? "no ordering constraint from this batch — it may run at any point"
|
|
504
|
+
: `run AFTER wave ${d.afterWave}${d.because.length > 0 ? ` (${d.because.join("; ")})` : ""}`;
|
|
505
|
+
return ` ${d.key} ${where} [${d.extId}]`;
|
|
506
|
+
}),
|
|
507
|
+
...(input.deletions.some((d) => d.createdInBatch)
|
|
508
|
+
? [
|
|
509
|
+
"",
|
|
510
|
+
"One or more of these deletes a component THIS BATCH CREATES. That is legal and ordered, but say it",
|
|
511
|
+
"out loud on the approval card — two requirements building something a third removes is a plan the",
|
|
512
|
+
"operator should get to reconsider, not a sequencing detail."
|
|
513
|
+
]
|
|
514
|
+
: []),
|
|
515
|
+
"",
|
|
516
|
+
"To execute, pass them to direct_apply's `deletes` yourself, in the shape below. Deliberately NOT automatic:",
|
|
517
|
+
"destruction is a decision, and a destructive package ROLLS BACK WHOLE — one member the org refuses takes",
|
|
518
|
+
"every other member down with it, so validate first (`dryRunOnly: true`, which gives each member its own",
|
|
519
|
+
"verdict) and split the call rather than shipping a refused member beside a clean one.",
|
|
520
|
+
...input.deletions.map((d) => ` {"typeName": "${d.typeName}", "fullName": "${d.fullName}"}`)
|
|
521
|
+
]
|
|
522
|
+
: []),
|
|
523
|
+
...(planning.createOnExisting.length > 0
|
|
524
|
+
? [
|
|
525
|
+
"",
|
|
526
|
+
`${planning.createOnExisting.length} CREATE ON AN EXISTING COMPONENT — both facts, because only you can say which is stale:`,
|
|
527
|
+
...planning.createOnExisting.map((h) => ` ${h.key}: ${h.extId} entry ${h.index} says intent "create" (${h.intentSource}), and this session's projection observed it PRESENT in the target org.`),
|
|
528
|
+
"",
|
|
529
|
+
"A create against a component that already exists deploys a full-file replacement over it, so",
|
|
530
|
+
"every element the authored file omits is lost. If it is meant to CHANGE rather than be born,",
|
|
531
|
+
"declare that entry `intent: \"update\"` and author its complete desired state. Not refused: a",
|
|
532
|
+
"projection is a point-in-time read, and the component may have been deleted since."
|
|
533
|
+
]
|
|
534
|
+
: []),
|
|
535
|
+
...(planning.hazards.length > 0
|
|
536
|
+
? [
|
|
537
|
+
"",
|
|
538
|
+
`${planning.hazards.length} INTENT NOT VERIFIABLE — this session has not observed these components, so nothing here guessed:`,
|
|
539
|
+
...planning.hazards.flatMap((h) => indent(h.message))
|
|
540
|
+
]
|
|
541
|
+
: []),
|
|
542
|
+
...(planning.refusals.length > 0
|
|
543
|
+
? [
|
|
544
|
+
"",
|
|
545
|
+
`${planning.refusals.length} REFUSAL(S) in the plans themselves — these components are NOT in the waves above:`,
|
|
546
|
+
...planning.refusals.flatMap((r) => [` [${r.code}]`, ...indent(r.message, " ")])
|
|
547
|
+
]
|
|
548
|
+
: []),
|
|
549
|
+
...(planning.unplaced.length > 0
|
|
550
|
+
? ["", `${planning.unplaced.length} component(s) left unordered by the above — fix the plan, do not deploy around them:`, ...planning.unplaced.map((k) => ` ${k}`)]
|
|
551
|
+
: []),
|
|
552
|
+
...(duplicates.length > 0
|
|
553
|
+
? [
|
|
554
|
+
"",
|
|
555
|
+
`${duplicates.length} component(s) listed more than once with nothing to tell the listings apart:`,
|
|
556
|
+
...duplicates.map((d) => ` ${d}`),
|
|
557
|
+
"",
|
|
558
|
+
"If these are one component in one state, delete the duplicate entry. If they are two STATES of",
|
|
559
|
+
"it, declare them as two revisions — `intent` and its own `authoredFile` on each — and they",
|
|
560
|
+
"order into two waves instead of colliding in one."
|
|
561
|
+
]
|
|
562
|
+
: []),
|
|
563
|
+
...(lateFacets.length > 0
|
|
564
|
+
? [
|
|
565
|
+
"",
|
|
566
|
+
`${lateFacets.length} LATE FACET(S) — each parent is ordered as if independent, then needs a`,
|
|
567
|
+
"SECOND deploy once its child exists, carrying the element that references it:",
|
|
568
|
+
...lateFacets.map((f) => ` ${f.component} re-deploy after ${f.waitsFor}`),
|
|
569
|
+
"",
|
|
570
|
+
"Declare that second deploy as a SECOND REVISION of the parent: a second `components[]` entry",
|
|
571
|
+
"for the same component with `intent: \"update\"`, its own `authoredFile` carrying the complete",
|
|
572
|
+
"desired state, and `dependsOn` naming the child ON THAT SECOND ENTRY. Re-run this and the",
|
|
573
|
+
"later revision orders AFTER the child — no wave is built by hand, and both files lint.",
|
|
574
|
+
"A late facet that PERSISTS after the split means the child edge still sits on the FIRST",
|
|
575
|
+
"state's entry, where it must be dropped (honouring it there is a cycle) — move the",
|
|
576
|
+
"`dependsOn` to the later revision's entry."
|
|
577
|
+
]
|
|
578
|
+
: []),
|
|
579
|
+
...(cycles.length > 0
|
|
580
|
+
? [
|
|
581
|
+
"",
|
|
582
|
+
`${cycles.length} CYCLE(S) — these cannot be ordered as they stand, and are NOT in the waves above:`,
|
|
583
|
+
...cycles.map((c) => ` ${c.join(" -> ")} -> ${c[0]}`),
|
|
584
|
+
"",
|
|
585
|
+
"A cycle is usually one component that must be deployed twice: an early form without the facet",
|
|
586
|
+
"that creates the loop, then a re-deploy carrying it once the other side exists. CustomObject +",
|
|
587
|
+
"compactLayoutAssignment is the common one. Declare it as TWO REVISIONS — two `components[]`",
|
|
588
|
+
"entries for that component, `intent: \"create\"` then `intent: \"update\"`, each bound to its own",
|
|
589
|
+
"`authoredFile` — and re-run this. The split is yours to author; this tool names it and orders",
|
|
590
|
+
"it, and never writes it for you: what the second state must CONTAIN is the part it cannot know."
|
|
591
|
+
]
|
|
592
|
+
: []),
|
|
593
|
+
...(input.unorderable.length > 0
|
|
594
|
+
? ["", `${input.unorderable.length} component(s) CANNOT be ordered and are excluded — fix the plan, do not deploy around them:`, ...input.unorderable.map((u) => ` ${u}`)]
|
|
595
|
+
: []),
|
|
596
|
+
...(planning.routes.length > 0
|
|
597
|
+
? [
|
|
598
|
+
"",
|
|
599
|
+
`${planning.routes.length} entr(y/ies) marked deployable:false — read as ROUTES, not owned components:`,
|
|
600
|
+
...planning.routes.map((r) => ` ${r.key} (${r.extId}, deployable:false — treated as a route, not an owner)`)
|
|
601
|
+
]
|
|
602
|
+
: []),
|
|
603
|
+
...(input.skippedEntries.length > 0
|
|
604
|
+
? ["", `${input.skippedEntries.length} plan entr(y/ies) could not be identified and were skipped:`, ...input.skippedEntries.map((s) => ` ${s}`)]
|
|
605
|
+
: []),
|
|
606
|
+
...(input.unreadablePlans.length > 0
|
|
607
|
+
? ["", `${input.unreadablePlans.length} plan.json could not be READ — that is not the same as a requirement owning nothing:`, ...input.unreadablePlans.map((u) => ` ${u}`)]
|
|
608
|
+
: []),
|
|
609
|
+
...(input.requirementsWithoutPlan.length > 0 ? ["", `no plan.json yet: ${input.requirementsWithoutPlan.join(", ")}`] : []),
|
|
610
|
+
...(unowned.length > 0
|
|
611
|
+
? ["", `depends on ${unowned.length} component(s) nothing here owns — assumed already in the org:`, ...unowned.slice(0, 8).map((u) => ` ${u}`)]
|
|
612
|
+
: []),
|
|
613
|
+
"",
|
|
614
|
+
"Fewer waves is fewer checkOnly round-trips, but a deploy is all-or-nothing: one bad element",
|
|
615
|
+
"rolls back its whole wave. Split further if you have a reason this cannot see; never merge",
|
|
616
|
+
"across a dependency edge."
|
|
617
|
+
];
|
|
618
|
+
return lines.join("\n");
|
|
619
|
+
}
|
|
620
|
+
/** The session's target org alias, for scoping the projection. Local read; never contacts the org. */
|
|
621
|
+
async function sessionTargetOrg(sessionId) {
|
|
622
|
+
try {
|
|
623
|
+
return (await resolveSessionOrg(await loadSession(sessionId), "target", { verify: false })).alias;
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
export function registerWaveTools(server) {
|
|
630
|
+
server.registerTool("plan_waves", {
|
|
631
|
+
title: "Derive the deploy order from plan.json dependencies",
|
|
632
|
+
description: "Topologically order every owned component into the FEWEST waves the batch allows, from the dependsOn each analyzer already recorded — every wave costs a ~45s checkOnly, so wave count is the biggest lever on run time. " +
|
|
633
|
+
"Feed each returned wave's components straight to direct_lint/direct_apply as `waveComponents`; an entry carrying a `path` names WHICH of that component's authored files this wave deploys. A component with two states in the batch orders as an ordinary two-wave edge, never a cycle. " +
|
|
634
|
+
"Cross-checks each declared intent against this session's projection before anything touches the org, and names each case: a create on a component already there, an update on one the projection found absent, a component this session never projected. " +
|
|
635
|
+
"A component whose last declared state is `intent: \"delete\"` is NOT a wave — it comes back as an ordering constraint plus the member list in direct_apply's `deletes` shape. Nothing is deleted by calling this; pass those members to direct_apply yourself after a `dryRunOnly` pass, because a destructive package rolls back whole. " +
|
|
636
|
+
"Reports cycles and late facets with the two-revision split they need rather than refusing, and lists dependencies nothing in the batch owns. " +
|
|
637
|
+
"REPORT-ONLY: it never edits plan.json and never mutates an org. Advisory: split a wave further if you have a reason this cannot see, but do not merge across a dependency edge.",
|
|
638
|
+
inputSchema: {
|
|
639
|
+
sessionId: z.string().describe("The session whose analyzer plans to order.")
|
|
640
|
+
}
|
|
641
|
+
}, async ({ sessionId }) => {
|
|
642
|
+
try {
|
|
643
|
+
const root = sessionDir(sessionId);
|
|
644
|
+
const index = await loadRequirementsIndex(sessionId);
|
|
645
|
+
if (index.length === 0)
|
|
646
|
+
return fail("NO_REQUIREMENTS", `Session ${sessionId} has no imported requirements.`);
|
|
647
|
+
// An unmerged fragment set must NOT fall through to the "no plan.json yet" advisory below:
|
|
648
|
+
// that line means "the analyzer has not run", which the supervisor knowingly waits out —
|
|
649
|
+
// whereas fragments without a canonical plan mean the merge was SKIPPED, and ordering the
|
|
650
|
+
// rest of the batch around it silently shrinks the batch by a whole requirement. Same
|
|
651
|
+
// gate verifies a completed merge (dropped or doubly-owned fragment components), which the
|
|
652
|
+
// contract has promised since the split shipped ("refuses a malformed or missing merge").
|
|
653
|
+
const fragmentAudit = await auditPlanFragments(root, index.map((entry) => entry.extId));
|
|
654
|
+
if (!fragmentAudit.clean)
|
|
655
|
+
return fail("PLAN_FRAGMENTS_UNRESOLVED", renderFragmentAudit(fragmentAudit));
|
|
656
|
+
const entries = [];
|
|
657
|
+
const parseRefusals = [];
|
|
658
|
+
const noPlan = [];
|
|
659
|
+
const unreadablePlans = [];
|
|
660
|
+
const unorderable = [];
|
|
661
|
+
const skippedEntries = [];
|
|
662
|
+
for (const entry of index) {
|
|
663
|
+
const result = await readPlanFile(path.join(root, "requirements", entry.extId, "plan.json"), entry.extId);
|
|
664
|
+
if (!result.ok) {
|
|
665
|
+
// A plan that could not be READ is not a requirement that owns nothing. Reading the two
|
|
666
|
+
// as one is how a malformed plan.json silently shrinks a batch — the previous
|
|
667
|
+
// `.catch(() => null)` did exactly that.
|
|
668
|
+
if (result.error.code === "PLAN_NOT_FOUND")
|
|
669
|
+
noPlan.push(entry.extId);
|
|
670
|
+
else
|
|
671
|
+
unreadablePlans.push(`${entry.extId}: ${result.error.code} — ${result.error.message}`);
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
parseRefusals.push(...result.plan.refusals);
|
|
675
|
+
for (const s of result.plan.skipped)
|
|
676
|
+
skippedEntries.push(`${entry.extId} entry ${s.index}: ${s.reason}`);
|
|
677
|
+
for (const c of result.plan.entries) {
|
|
678
|
+
// A placeholder an analyzer left behind was once ordered into a wave as though it were
|
|
679
|
+
// real, so the plan looked complete and failed at assembly. Refuse to order it and say
|
|
680
|
+
// which plan to fix — but see isPlaceholderFullName for why this must be type-aware.
|
|
681
|
+
// Deployable entries only: a route's fullName is prose about work leaving by another
|
|
682
|
+
// path, and it is reported as a route below rather than as a broken name. A DELETE is
|
|
683
|
+
// checked whatever its `deployable` says: its fullName becomes a destructive package
|
|
684
|
+
// member, where an unresolved placeholder is worse than it would be in a wave.
|
|
685
|
+
if ((c.deployable || c.intent === "delete") && isPlaceholderFullName(c.typeName, c.fullName)) {
|
|
686
|
+
unorderable.push(`${entry.extId}: ${c.key} — looks like an unresolved placeholder, not a real fullName; resolve it in that plan.json${c.typeName === "Layout" ? "" : " (note: Layout fullNames legitimately contain spaces and are ordered normally)"}`);
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
entries.push(c);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (!entries.some((e) => e.deployable)) {
|
|
693
|
+
return fail("NO_COMPONENTS", [
|
|
694
|
+
`No plan.json in this session owns any components${noPlan.length ? ` (${noPlan.length} requirement(s) have no plan yet)` : ""}. Run the analyzers first.`,
|
|
695
|
+
// Said separately, because "the analyzer has not run" and "the analyzer wrote
|
|
696
|
+
// something this cannot read" need opposite responses from the reader.
|
|
697
|
+
...(unreadablePlans.length > 0 ? [`${unreadablePlans.length} plan.json could not be READ:`, ...unreadablePlans.map((u) => ` ${u}`)] : [])
|
|
698
|
+
].join("\n"));
|
|
699
|
+
}
|
|
700
|
+
// LOCAL DISK ONLY — the session's own project_org runs plus every completed wave's
|
|
701
|
+
// post-image. No org round-trip: this step runs before the operator has approved anything,
|
|
702
|
+
// and a planner that reads the org is a planner that can fail on an expired session.
|
|
703
|
+
const view = await collectSessionProjection(root, await sessionTargetOrg(sessionId));
|
|
704
|
+
const grounding = view.sources.length === 0 ? "no projection yet — run project_org" : describeSources(view);
|
|
705
|
+
const planning = planRevisions(entries, projectionExistence(view), parseRefusals);
|
|
706
|
+
const plan = planWaves(planning.owned);
|
|
707
|
+
const deletions = placeDeletions(plan, planning.deletions);
|
|
708
|
+
// PERSIST THE DERIVATION. This tool is advisory and stays advisory — but until now nothing
|
|
709
|
+
// downstream could tell a deliberate hand-order from an accidental one, because the derived
|
|
710
|
+
// answer existed only in the conversation. Written to the session so `direct_apply` can WARN
|
|
711
|
+
// (never refuse) when the wave it is handed corresponds to no wave derived here. Fail-open:
|
|
712
|
+
// a derivation that cannot be written costs one later warning and never this plan.
|
|
713
|
+
await writeWaveDerivation(sessionId, plan.waves, grounding);
|
|
714
|
+
// PERSIST THE DENOMINATOR, at Configuration Item grain.
|
|
715
|
+
//
|
|
716
|
+
// This tool already KNEW what the batch was asked for — `distinct` below is printed to the
|
|
717
|
+
// operator mid-run as "N of M component(s) ordered" — and until now that was the only place
|
|
718
|
+
// it existed. QA round 2: a 2h45m run delivered 19/20 requirements and ~140 components, and
|
|
719
|
+
// the close report could state neither number, because nothing carried the planned count to
|
|
720
|
+
// the end. See `direct/plannedScope.ts` for why this is a separate artifact from the wave
|
|
721
|
+
// derivation written above: different grain, and a count cannot inherit a warning's
|
|
722
|
+
// fail-open posture. Fail-open on the WRITE only — a run never fails over bookkeeping.
|
|
723
|
+
await writePlannedScope(sessionId, {
|
|
724
|
+
components: plannedConfigurationItems(await plannedInputs(root, entries.filter((e) => e.deployable))),
|
|
725
|
+
// Routes a deploy COULD have satisfied belong in the denominator (the operator approved
|
|
726
|
+
// them) and never in the numerator. Routes NO deploy could ever satisfy — sObject rows
|
|
727
|
+
// (membership, assignment, custom setting values) and the no-API cases — are recorded
|
|
728
|
+
// apart and land in NEITHER half of the rate: counting them against automation makes
|
|
729
|
+
// the metric measure the requirement mix, not the tool (QA2-008 Class A).
|
|
730
|
+
manual: plannedConfigurationItems(planning.routes.filter((r) => r.routeCause === null).map((r) => ({ typeName: r.typeName, fullName: r.fullName, extId: r.extId }))),
|
|
731
|
+
unautomatable: plannedConfigurationItems(planning.routes.filter((r) => r.routeCause !== null).map((r) => ({ typeName: r.typeName, fullName: r.fullName, extId: r.extId }))),
|
|
732
|
+
groundedAgainst: grounding
|
|
733
|
+
});
|
|
734
|
+
const distinct = new Set([...planning.owned.map((o) => o.key), ...planning.unplaced]).size;
|
|
735
|
+
const text = renderWavePlan({
|
|
736
|
+
plan,
|
|
737
|
+
planning,
|
|
738
|
+
deletions,
|
|
739
|
+
distinct,
|
|
740
|
+
grounding,
|
|
741
|
+
requirementsWithoutPlan: noPlan,
|
|
742
|
+
unreadablePlans,
|
|
743
|
+
unorderable,
|
|
744
|
+
skippedEntries
|
|
745
|
+
});
|
|
746
|
+
const placedKeys = new Set(plan.waves.flat().map((c) => `${c.typeName}:${c.fullName}`));
|
|
747
|
+
return ok(text, {
|
|
748
|
+
// Structured data ONLY. This object previously spread the same PROSE ARRAYS used for the
|
|
749
|
+
// text rendering into an object literal, so every advisory line arrived as a numerically
|
|
750
|
+
// keyed field ("0", "1", "2"…) mixed in with waveCount and waves — unreadable as data and
|
|
751
|
+
// duplicating the text half for no gain.
|
|
752
|
+
waveCount: plan.waves.length,
|
|
753
|
+
ordered: placedKeys.size,
|
|
754
|
+
total: distinct,
|
|
755
|
+
waves: plan.waves,
|
|
756
|
+
groundedAgainst: grounding,
|
|
757
|
+
...(planning.inferences.length > 0 ? { inferredIntents: planning.inferences.map((i) => ({ key: i.key, extId: i.extId, index: i.index, intent: i.intent, from: i.from })) } : {}),
|
|
758
|
+
// The `deletes` half is EXACTLY direct_apply's argument shape, so the supervisor lifts it
|
|
759
|
+
// rather than retyping it; `deletions` carries the ordering beside it. Neither is a
|
|
760
|
+
// trigger — this tool never deletes, and nothing downstream reads these as approval.
|
|
761
|
+
...(deletions.length > 0
|
|
762
|
+
? {
|
|
763
|
+
deletions: deletions.map((d) => ({
|
|
764
|
+
typeName: d.typeName,
|
|
765
|
+
fullName: d.fullName,
|
|
766
|
+
requirement: d.extId,
|
|
767
|
+
afterWave: d.afterWave,
|
|
768
|
+
createdInBatch: d.createdInBatch,
|
|
769
|
+
...(d.unplaceable ? { unplaceable: true } : {})
|
|
770
|
+
})),
|
|
771
|
+
deletes: deletions.map((d) => ({ typeName: d.typeName, fullName: d.fullName }))
|
|
772
|
+
}
|
|
773
|
+
: {}),
|
|
774
|
+
...(planning.createOnExisting.length > 0 ? { createOnExisting: planning.createOnExisting } : {}),
|
|
775
|
+
...(planning.hazards.length > 0 ? { unverifiableIntents: planning.hazards.map((h) => ({ key: h.key, reason: h.reason, message: h.message })) } : {}),
|
|
776
|
+
...(planning.refusals.length > 0 ? { revisionRefusals: planning.refusals } : {}),
|
|
777
|
+
...(planning.unplaced.length > 0 ? { unplaced: planning.unplaced } : {}),
|
|
778
|
+
...(plan.duplicates.length > 0 ? { duplicates: plan.duplicates } : {}),
|
|
779
|
+
...(plan.lateFacets.length > 0 ? { lateFacets: plan.lateFacets } : {}),
|
|
780
|
+
...(plan.cycles.length > 0 ? { cycles: plan.cycles } : {}),
|
|
781
|
+
...(noPlan.length > 0 ? { requirementsWithoutPlan: noPlan } : {}),
|
|
782
|
+
...(unreadablePlans.length > 0 ? { unreadablePlans } : {}),
|
|
783
|
+
...(plan.unowned.length > 0 ? { externalDependencies: plan.unowned } : {}),
|
|
784
|
+
...(unorderable.length > 0 ? { unorderable } : {})
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
catch (error) {
|
|
788
|
+
if (error instanceof WorkspaceError)
|
|
789
|
+
return fail(error.code, error.message);
|
|
790
|
+
return internalError(error);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
}
|