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,1360 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { runProjection, ScopeTooLargeError } from "../projection/project.js";
|
|
4
|
+
import { diffMetadata } from "../projection/diff.js";
|
|
5
|
+
import { walkSourceTree } from "./sourceTree.js";
|
|
6
|
+
import { runDirectApply } from "./applyLoop.js";
|
|
7
|
+
import { SETUP_AUDIT_TRAIL_SOQL, TOOLING_CUSTOM_FIELD_SOQL, classifyRollback, isUnverifiedLapse, recheckUndeleteWindow, tableEnumOrId } from "./rollback.js";
|
|
8
|
+
// `direct_rollback`'s ENGINE (plan 2026-08-02-002 U10; origin R17, R19–R21; AE4, AE5).
|
|
9
|
+
//
|
|
10
|
+
// U9 classifies and touches nothing. This module is the other half: it supplies the live-org reads
|
|
11
|
+
// the classifier declares as a seam, decides what the tool may DO with each class, writes the cards
|
|
12
|
+
// an operator reads, and — for the components that survive all of that — executes through
|
|
13
|
+
// `runDirectApply` so a rollback is an ordinary wave with its own bundle, verdicts, diff and
|
|
14
|
+
// revert-marked commit (R21).
|
|
15
|
+
//
|
|
16
|
+
// ---------------------------------------------------------------------------------------------
|
|
17
|
+
// PARTIAL BY DEFAULT IS THE WHOLE POSTURE (R17, R20)
|
|
18
|
+
//
|
|
19
|
+
// A rollback selection is a list of independent decisions, not a transaction. An irreversible
|
|
20
|
+
// component is refused and the rest of the selection still executes; a lossy one waits for its own
|
|
21
|
+
// named ack while its clean neighbours go; an undelete-window one leaves through Setup and cannot
|
|
22
|
+
// block anything. The one place this is NOT true is Salesforce's own destructive package, which
|
|
23
|
+
// rolls back whole — so a rollback whose deletes are refused by the org takes its own deletes down
|
|
24
|
+
// with them, and nothing here can change that. What this module guarantees is that a refusal HERE
|
|
25
|
+
// never becomes a refusal THERE.
|
|
26
|
+
//
|
|
27
|
+
// ---------------------------------------------------------------------------------------------
|
|
28
|
+
// THE ADDRESS IS (sessionId, wave), NEVER A SHA (R17)
|
|
29
|
+
//
|
|
30
|
+
// The mirror is a rebuildable cache and every rebuild rewrites its SHAs by design (the materializer
|
|
31
|
+
// version is a commit trailer). `direct_history` says so on every line it prints, and this tool is
|
|
32
|
+
// the reason it has to: a rollback addressed by a SHA an operator wrote down last week is a
|
|
33
|
+
// rollback aimed at whatever that SHA means today, which is nothing. The bundle directory
|
|
34
|
+
// `<session>/direct/<wave>` is the stable identity, so that is the address.
|
|
35
|
+
//
|
|
36
|
+
// ---------------------------------------------------------------------------------------------
|
|
37
|
+
// A CHECK THAT WAS ATTEMPTED IS NOT A CHECK THAT ANSWERED
|
|
38
|
+
//
|
|
39
|
+
// The doctrine everything above rests on is that a check which did not run is not a check that
|
|
40
|
+
// found nothing — it is why NOT_COMPARABLE is an ack, why presence withholds instead of defaulting
|
|
41
|
+
// to absent, and why the not-read branch of the plan header spells out that no divergence is
|
|
42
|
+
// reported because none was looked for. The SUMMARY LINES are where that discipline is easiest to
|
|
43
|
+
// lose, because a summary is a boolean's natural home and these two facts do not fit in one:
|
|
44
|
+
//
|
|
45
|
+
// ATTEMPTED a reader was built, a probe object exists, the call went out
|
|
46
|
+
// ANSWERED the org came back with something to compare against
|
|
47
|
+
//
|
|
48
|
+
// An org token that expires mid-session separates them completely — the reader CONSTRUCTS, because
|
|
49
|
+
// a refresh token is only spent on the first request, and then every query behind it fails.
|
|
50
|
+
// `LiveStateReport` here and `ProbeOutcome` in the classifier each carry BOTH halves in one status,
|
|
51
|
+
// plus what the gap leaves UNVERIFIED, and `liveStateLines` / `probeLines` render the partial cases
|
|
52
|
+
// as partial. No summary line in this module may resolve off the attempted half alone — and neither
|
|
53
|
+
// type carries a separate `attempted` boolean, because a boolean holding half of what the status
|
|
54
|
+
// holds all of is the exact shape that caused this.
|
|
55
|
+
// ---------------------------------------------------------------------------------------------
|
|
56
|
+
// the live-org probe — the implementation of U9's seam
|
|
57
|
+
// ---------------------------------------------------------------------------------------------
|
|
58
|
+
/** SOQL literals here are org-derived names; anything outside this shape is refused, never quoted. */
|
|
59
|
+
const SOQL_SAFE = /^[A-Za-z0-9_]{1,80}$/;
|
|
60
|
+
/**
|
|
61
|
+
* The object as the Tooling API addresses it — and this is NOT the API name for a custom object.
|
|
62
|
+
*
|
|
63
|
+
* MEASURED, T-Pinto 00Dfn00000BFjbBEAT, 2026-08-03 (read-only):
|
|
64
|
+
*
|
|
65
|
+
* TableEnumOrId = 'PV_Asset__c' -> 0 rows
|
|
66
|
+
* TableEnumOrId = '01Ifn000003E4hx' -> 8 rows (the object's EntityDefinition DurableId)
|
|
67
|
+
* TableEnumOrId = 'Account' -> 361 rows (a standard object IS its own DurableId)
|
|
68
|
+
*
|
|
69
|
+
* 2026-08-02-005 probed a standard object only, so its `TableEnumOrId='Account'` recipe reads as
|
|
70
|
+
* general and is not. Getting this wrong is SILENT in the dangerous direction: zero Tooling rows
|
|
71
|
+
* means an empty deleted set, which the classifier reads as "Salesforce has purged it" and cards as
|
|
72
|
+
* permanent data loss over a field still sitting in Setup's Deleted Fields list. Hence one
|
|
73
|
+
* `EntityDefinition` read per object before anything else, cached for the run.
|
|
74
|
+
*
|
|
75
|
+
* The labels come along because the audit trail needs them (`scopeAuditRows`), and both are one
|
|
76
|
+
* query.
|
|
77
|
+
*/
|
|
78
|
+
async function resolveObject(reader, objectName) {
|
|
79
|
+
if (!SOQL_SAFE.test(objectName)) {
|
|
80
|
+
throw new Error(`refusing to query for object name ${JSON.stringify(objectName)} — not a plain API name.`);
|
|
81
|
+
}
|
|
82
|
+
const result = await reader.toolingQuery(`SELECT DurableId, QualifiedApiName, Label, PluralLabel FROM EntityDefinition WHERE QualifiedApiName = '${objectName}'`);
|
|
83
|
+
const row = result.records[0];
|
|
84
|
+
// NO FALLBACK TO THE API NAME. Falling back used to look harmless — a standard object IS its own
|
|
85
|
+
// DurableId — but it is the same zero-rows trap from the other side: for a CUSTOM object the API
|
|
86
|
+
// name returns 0 Tooling rows, which is an empty deleted set, which classifies as LOSSY_RECREATE
|
|
87
|
+
// and tells an operator the data is permanently gone about a field still sitting in Setup. An
|
|
88
|
+
// unresolvable object is a probe that COULD NOT RUN, and `probeObject` turns that into presence
|
|
89
|
+
// UNVERIFIED — the safe direction, and the one this module states everywhere else.
|
|
90
|
+
if (typeof row?.DurableId !== "string" || row.DurableId.length === 0) {
|
|
91
|
+
throw new Error(`EntityDefinition has no DurableId for ${objectName}, so CustomField.TableEnumOrId cannot be addressed for it. ` +
|
|
92
|
+
`Querying by API name instead would return zero rows for a custom object, which reads downstream as "Salesforce ` +
|
|
93
|
+
`purged this field and its data" — so presence is left UNVERIFIED rather than guessed at.`);
|
|
94
|
+
}
|
|
95
|
+
const durableId = tableEnumOrId(row.DurableId);
|
|
96
|
+
return {
|
|
97
|
+
durableId,
|
|
98
|
+
// A standard object's DurableId IS its API name; a custom one's is an `01I…` key. Derived from
|
|
99
|
+
// the org's own answer rather than sniffed off a `__c` suffix, which would also have to know
|
|
100
|
+
// about `__mdt`, `__e`, `__b` and `__x`.
|
|
101
|
+
custom: durableId !== objectName,
|
|
102
|
+
labels: [row?.Label, row?.PluralLabel]
|
|
103
|
+
.filter((label) => typeof label === "string" && label.length > 0)
|
|
104
|
+
.map((label) => label.toLowerCase())
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The audit rows that plausibly concern THIS object, from a query that cannot be scoped to it.
|
|
109
|
+
*
|
|
110
|
+
* `Display` is unfilterable server-side (probe §4 trap 1) and the object appears in two different
|
|
111
|
+
* places depending on the object's kind, both measured:
|
|
112
|
+
*
|
|
113
|
+
* standard (`deletedCF`) Section = "Customize Accounts" — the PLURAL label
|
|
114
|
+
* custom (`deletedCFCustom`) Section = "Custom Objects" — no object at all; the object is
|
|
115
|
+
* the Display tail, "… from PV Assets" — again the PLURAL label
|
|
116
|
+
*
|
|
117
|
+
* (The second row is new here: 2026-08-02-005 §4 measured the standard shape and inferred the
|
|
118
|
+
* custom one from its rendering. Confirmed against T-Pinto's two `PV_Asset__c` deletions on
|
|
119
|
+
* 2026-08-03.)
|
|
120
|
+
*
|
|
121
|
+
* NARROWING ONLY, NEVER EMPTYING. If the filter matches nothing while the unscoped set is
|
|
122
|
+
* non-empty, the unscoped set is returned: this is corroboration for a timestamp the bundle usually
|
|
123
|
+
* already has, and every fact it contributes is reported INFERRED, so an over-broad set costs
|
|
124
|
+
* precision (`auditRowsMatched > 1`, which the classifier surfaces) while an empty one silently
|
|
125
|
+
* removes the only cross-check there is.
|
|
126
|
+
*/
|
|
127
|
+
export function scopeAuditRows(rows, identity, sections) {
|
|
128
|
+
if (identity.labels.length === 0)
|
|
129
|
+
return rows;
|
|
130
|
+
const matches = rows.filter((row, index) => {
|
|
131
|
+
if (identity.custom) {
|
|
132
|
+
const display = row.display.toLowerCase();
|
|
133
|
+
const sep = display.lastIndexOf(" from ");
|
|
134
|
+
return sep >= 0 && identity.labels.includes(display.slice(sep + " from ".length).trim());
|
|
135
|
+
}
|
|
136
|
+
const section = (sections?.[index] ?? "").toLowerCase();
|
|
137
|
+
return identity.labels.some((label) => section === `customize ${label}`);
|
|
138
|
+
});
|
|
139
|
+
return matches.length > 0 ? matches : rows;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The three read-only surfaces U9's `RollbackOrgProbe` declares, over the live org.
|
|
143
|
+
*
|
|
144
|
+
* READ-ONLY BY CONSTRUCTION: two Tooling queries and one SOQL query, no metadata call, no deploy,
|
|
145
|
+
* nothing that could undelete. The classifier cannot reach an org at all; this is the only place
|
|
146
|
+
* that can, which is what keeps U9 testable against transcribed fixtures.
|
|
147
|
+
*/
|
|
148
|
+
export function liveRollbackProbe(reader) {
|
|
149
|
+
const identities = new Map();
|
|
150
|
+
const identity = (objectName) => {
|
|
151
|
+
let pending = identities.get(objectName);
|
|
152
|
+
if (!pending) {
|
|
153
|
+
pending = resolveObject(reader, objectName);
|
|
154
|
+
identities.set(objectName, pending);
|
|
155
|
+
}
|
|
156
|
+
return pending;
|
|
157
|
+
};
|
|
158
|
+
return {
|
|
159
|
+
async toolingCustomFields(objectName) {
|
|
160
|
+
const object = await identity(objectName);
|
|
161
|
+
const result = await reader.toolingQuery(TOOLING_CUSTOM_FIELD_SOQL(object.durableId));
|
|
162
|
+
return result.records
|
|
163
|
+
.filter((row) => typeof row.DeveloperName === "string")
|
|
164
|
+
.map((row) => ({ developerName: row.DeveloperName, namespacePrefix: row.NamespacePrefix ?? null }));
|
|
165
|
+
},
|
|
166
|
+
/**
|
|
167
|
+
* The LIVE custom-field set, from `FieldDefinition` rather than `listMetadata`.
|
|
168
|
+
*
|
|
169
|
+
* 2026-08-02-005 §3 established `Tooling ∖ listMetadata` as the detector and warned off
|
|
170
|
+
* `describe` as the live baseline (FLS hides 133 live fields on this org, which would read as
|
|
171
|
+
* deletions). `FieldDefinition` is the third candidate and the probe dismissed it in one line
|
|
172
|
+
* for showing "nothing" for deleted fields — which is exactly the property the live side needs.
|
|
173
|
+
* Measured as a set, T-Pinto, 2026-08-03:
|
|
174
|
+
*
|
|
175
|
+
* Account: Tooling 361, FieldDefinition 327, difference 34, all `_del`, reverse empty
|
|
176
|
+
* PV_Asset__c: Tooling 8, FieldDefinition 6, difference 2, both `_del`, reverse empty
|
|
177
|
+
*
|
|
178
|
+
* The Account numbers are the addendum's `listMetadata` numbers exactly (361/327/34/0), so this
|
|
179
|
+
* reproduces the sanctioned detector rather than substituting a weaker one — and it is scoped
|
|
180
|
+
* to ONE OBJECT over the Tooling connection the reader already holds, where `listMetadata`
|
|
181
|
+
* returns the whole org (~10.8 MB measured) and needs a Metadata API call this reader does not
|
|
182
|
+
* expose. `EntityDefinition.QualifiedApiName` filters both object kinds with no durable-id
|
|
183
|
+
* indirection, and `QualifiedApiName` carries the namespace the way `listMetadata` does
|
|
184
|
+
* (`SBQQ__Foo__c`), which is the refinement the whole detector turns on.
|
|
185
|
+
*/
|
|
186
|
+
async listCustomFields(objectName) {
|
|
187
|
+
if (!SOQL_SAFE.test(objectName))
|
|
188
|
+
return [];
|
|
189
|
+
const result = await reader.toolingQuery(`SELECT QualifiedApiName FROM FieldDefinition WHERE EntityDefinition.QualifiedApiName = '${objectName}'`);
|
|
190
|
+
return result.records
|
|
191
|
+
.map((row) => row.QualifiedApiName)
|
|
192
|
+
.filter((name) => typeof name === "string" && name.endsWith("__c"))
|
|
193
|
+
.map((name) => `${objectName}.${name}`)
|
|
194
|
+
.sort();
|
|
195
|
+
},
|
|
196
|
+
async fieldDeleteAudit(objectName) {
|
|
197
|
+
// Optional on the reader interface, and its absence is not a failure: the audit trail is
|
|
198
|
+
// corroboration for a timestamp the bundle's own mutation window usually supplies, so losing
|
|
199
|
+
// it costs an INFERRED term and never a class.
|
|
200
|
+
if (!reader.soqlQuery)
|
|
201
|
+
return [];
|
|
202
|
+
const object = await identity(objectName);
|
|
203
|
+
const result = await reader.soqlQuery(SETUP_AUDIT_TRAIL_SOQL(!object.custom));
|
|
204
|
+
const rows = [];
|
|
205
|
+
const sections = [];
|
|
206
|
+
for (const row of result.records) {
|
|
207
|
+
if (typeof row.CreatedDate !== "string" || typeof row.Display !== "string")
|
|
208
|
+
continue;
|
|
209
|
+
rows.push({ createdDate: row.CreatedDate, display: row.Display });
|
|
210
|
+
sections.push(typeof row.Section === "string" ? row.Section : "");
|
|
211
|
+
}
|
|
212
|
+
return scopeAuditRows(rows, object, sections);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const componentKey = (c) => `${c.typeName}:${c.fullName}`;
|
|
217
|
+
/**
|
|
218
|
+
* A component read as `found: false` — but is that ABSENCE or a read that failed?
|
|
219
|
+
*
|
|
220
|
+
* The projection records both as `found: false` with an `errors` array, and the difference is the
|
|
221
|
+
* whole preflight: absence compared against a recorded post-image produces "this component is gone,
|
|
222
|
+
* something changed it outside this history", which is a confident accusation to make out of a
|
|
223
|
+
* network error. `runProjection`'s catch-all writes `read failed: …`; a genuine miss writes only
|
|
224
|
+
* the reader's `<Type>:<name> not found`. Anything that is not recognisably the latter is treated
|
|
225
|
+
* as UNKNOWN and the key is omitted, which the classifier reads as "no live state supplied".
|
|
226
|
+
*/
|
|
227
|
+
function isVerifiedAbsence(errors) {
|
|
228
|
+
if (!errors || errors.length === 0)
|
|
229
|
+
return true;
|
|
230
|
+
return errors.every((error) => /\bnot found$/i.test(error.trim()));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Read live state for the components a rollback would touch (origin R16).
|
|
234
|
+
*
|
|
235
|
+
* Through `runProjection`, deliberately, rather than by calling the snapshot readers directly: the
|
|
236
|
+
* recorded side of every comparison was written by that same function, and a preflight whose two
|
|
237
|
+
* sides come from two readers is the NOT_COMPARABLE finding this plan already had to invent once
|
|
238
|
+
* (PV Arm B). Same producer, same shape, and the projection lands in the rollback's own bundle as
|
|
239
|
+
* evidence of what was checked.
|
|
240
|
+
*/
|
|
241
|
+
export async function readLiveState(args) {
|
|
242
|
+
const scope = args.components.length;
|
|
243
|
+
if (scope === 0)
|
|
244
|
+
return { live: new Map(), unknown: [], unreadable: [], scope };
|
|
245
|
+
try {
|
|
246
|
+
const result = await runProjection(args.reader, {
|
|
247
|
+
scope: { components: args.components },
|
|
248
|
+
outDir: args.outDir,
|
|
249
|
+
orgAlias: args.orgAlias,
|
|
250
|
+
apiVersion: args.apiVersion,
|
|
251
|
+
label: "rollback-preflight"
|
|
252
|
+
});
|
|
253
|
+
const live = new Map();
|
|
254
|
+
const unknown = [];
|
|
255
|
+
const wanted = new Set(args.components.map(componentKey));
|
|
256
|
+
for (const entry of result.manifest.components) {
|
|
257
|
+
const key = componentKey(entry);
|
|
258
|
+
// Precedent samples ride along on every projection and are NOT this caller's scope. They
|
|
259
|
+
// would only ever add keys nobody asked about, but the mirror already learned once what
|
|
260
|
+
// happens when a sampled component is read as work somebody did.
|
|
261
|
+
if (entry.precedent || !wanted.has(key))
|
|
262
|
+
continue;
|
|
263
|
+
if (entry.found) {
|
|
264
|
+
live.set(key, result.byFile.get(entry.file) ?? null);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
if (isVerifiedAbsence(entry.errors))
|
|
268
|
+
live.set(key, null);
|
|
269
|
+
else
|
|
270
|
+
unknown.push(`${key} (${entry.errors?.join("; ") ?? "read failed"})`);
|
|
271
|
+
}
|
|
272
|
+
const unreadable = args.components.map(componentKey).filter((key) => !live.has(key));
|
|
273
|
+
return { live, unknown, unreadable, scope, dir: args.outDir };
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
// FAIL-OPEN, LOUDLY. A preflight that cannot run must not stop a rollback the operator has
|
|
277
|
+
// decided on — but it must never be mistaken for a preflight that ran and found nothing, which
|
|
278
|
+
// is why the caller prints this error on the card.
|
|
279
|
+
const message = error instanceof ScopeTooLargeError
|
|
280
|
+
? `${error.message} — narrow the selection with \`components\``
|
|
281
|
+
: error instanceof Error
|
|
282
|
+
? error.message
|
|
283
|
+
: String(error);
|
|
284
|
+
return { live: new Map(), unknown: [], unreadable: [], scope, error: message };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* A NOT_COMPARABLE divergence is an ACK here, though U9 leaves the gate at PROCEEDS.
|
|
289
|
+
*
|
|
290
|
+
* The classifier is right not to move it: not-comparable is the statement that the recorded and
|
|
291
|
+
* observed snapshots were produced by different readers, so their difference is not evidence about
|
|
292
|
+
* the org IN EITHER DIRECTION. It is not drift, and calling it drift is the false accusation PV Arm
|
|
293
|
+
* B produced (an entire Layout "changed" on reader shape alone, org state byte-identical).
|
|
294
|
+
*
|
|
295
|
+
* But the tool's question is different from the classifier's. What PROCEEDS would mean here is
|
|
296
|
+
* "overwrite live state without having been able to look at it", and the R16 preflight exists
|
|
297
|
+
* precisely so an overwrite is never issued blind. The conservative reading costs one ack; the
|
|
298
|
+
* permissive one silently spends the operator's only chance to notice that the check did not run.
|
|
299
|
+
* So: ack, worded as a gap rather than as a change.
|
|
300
|
+
*/
|
|
301
|
+
const notComparableAck = (component) => `${component.key}: the R16 preflight could not compare live state to what this wave recorded — ${component.divergence?.detail ?? "the two snapshots are not comparable"} ` +
|
|
302
|
+
`Rolling back overwrites whatever is live now, and nothing here can tell you whether that is what the wave left. This is a CHECK THAT DID NOT RUN, not a change that was found.`;
|
|
303
|
+
/**
|
|
304
|
+
* Same doctrine as `notComparableAck`, for its sibling gap: the preflight ASKED about this
|
|
305
|
+
* component and got no answer, so no drift comparison ran. PROCEEDS here would mean overwriting
|
|
306
|
+
* live state nobody could look at. Worded as a gap, never as a change that was found.
|
|
307
|
+
*/
|
|
308
|
+
const unreadableAck = (component) => `${component.key}: the R16 preflight tried to read this component's live state and could not — the read failed or returned nothing. ` +
|
|
309
|
+
`Rolling back overwrites whatever is live now, and this plan cannot tell you whether that matches what the wave left. ` +
|
|
310
|
+
`This is a CHECK THAT DID NOT RUN, not a change that was found.`;
|
|
311
|
+
/** Which components execute, which wait for a named ack, and which only ever get reported. */
|
|
312
|
+
export function gateRollback(plan, ackComponents, unreadable = new Set()) {
|
|
313
|
+
const acked = new Set(ackComponents);
|
|
314
|
+
return plan.components.map((component) => {
|
|
315
|
+
const notComparable = component.divergence?.kind === "NOT_COMPARABLE";
|
|
316
|
+
const unanswered = unreadable.has(component.key);
|
|
317
|
+
const ackReasons = [
|
|
318
|
+
...component.ackReasons,
|
|
319
|
+
...(notComparable ? [notComparableAck(component)] : []),
|
|
320
|
+
...(unanswered ? [unreadableAck(component)] : [])
|
|
321
|
+
];
|
|
322
|
+
// AE4, ASKED OF THE CLASS AND ASKED FIRST — layer 1 of three.
|
|
323
|
+
//
|
|
324
|
+
// Everything below this line dispatches on the GATE, which is the right question for every other
|
|
325
|
+
// class: the gate is where "this needs an ack" lives, and an ack is a real answer to drift, to an
|
|
326
|
+
// incomplete image, to a sharing-model recalculation. It is NOT a real answer to an in-window
|
|
327
|
+
// deleted field, because the thing an ack would release is the deploy, and the deploy is the
|
|
328
|
+
// harm: a re-create restores the definition, reports success, and throws away every value every
|
|
329
|
+
// record held while Setup still has the field AND ITS DATA.
|
|
330
|
+
//
|
|
331
|
+
// So the class decides here, and no gate can overrule it. This is not belt-and-braces around a
|
|
332
|
+
// hypothetical: `UNDELETE_WINDOW` + `ACK_REQUIRED` was REACHABLE, and by an ordinary route — the
|
|
333
|
+
// delete frees the original API name, something takes it, the R16 preflight finds the
|
|
334
|
+
// disagreement, and U9's divergence handling rewrote OPERATOR_ACTION into an ackable gate. That
|
|
335
|
+
// hole is closed in `rollback.ts` too, but AE4 is U10's invariant and U10 does not own that file.
|
|
336
|
+
//
|
|
337
|
+
// AND THE SAME LAYER COVERS THE LAPSE NOTHING VERIFIED, asked of `rollback.ts`'s own predicate
|
|
338
|
+
// rather than re-derived — a guard that re-derives is a guard that can come to disagree with the
|
|
339
|
+
// rule it guards. Today every producer of that state already sets `OPERATOR_ACTION`, so this
|
|
340
|
+
// changes no disposition; what it removes is the dependence on that. `rollbackWork` throws over
|
|
341
|
+
// one of these, which stops the harm but stops it as a CRASH at the end of a run, after the plan
|
|
342
|
+
// has already printed a release token for a component no acknowledgement may release. The gate is
|
|
343
|
+
// where that belongs, and it is exactly the rewrite `UNDELETE_WINDOW` above already survived.
|
|
344
|
+
if (component.class === "UNDELETE_WINDOW" || isUnverifiedLapse(component)) {
|
|
345
|
+
return { component, disposition: "OPERATOR_ACTION", ackReasons };
|
|
346
|
+
}
|
|
347
|
+
switch (component.gate) {
|
|
348
|
+
case "OPERATOR_ACTION":
|
|
349
|
+
return { component, disposition: "OPERATOR_ACTION", ackReasons };
|
|
350
|
+
case "REFUSED":
|
|
351
|
+
case "NO_ACTION":
|
|
352
|
+
case "BLOCKED_NO_IMAGE":
|
|
353
|
+
return { component, disposition: "REPORTED", ackReasons };
|
|
354
|
+
case "ACK_REQUIRED":
|
|
355
|
+
return acked.has(component.key)
|
|
356
|
+
? { component, disposition: "EXECUTE", ackKey: component.key, ackReasons }
|
|
357
|
+
: { component, disposition: "HELD_FOR_ACK", ackKey: component.key, ackReasons };
|
|
358
|
+
case "PROCEEDS":
|
|
359
|
+
if (!notComparable && !unanswered)
|
|
360
|
+
return { component, disposition: "EXECUTE", ackReasons };
|
|
361
|
+
return acked.has(component.key)
|
|
362
|
+
? { component, disposition: "EXECUTE", ackKey: component.key, ackReasons }
|
|
363
|
+
: { component, disposition: "HELD_FOR_ACK", ackKey: component.key, ackReasons };
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* THE POST-UNDELETE CHECKLIST (origin R19), written here because it existed nowhere.
|
|
369
|
+
*
|
|
370
|
+
* R19 names five things — strip the `_del` suffix, re-check required/unique, field dependencies,
|
|
371
|
+
* master-detail conversion, layout membership — and the plan carries no text for any of them. What
|
|
372
|
+
* follows is that list turned into instructions, and the load-bearing editorial decision is the
|
|
373
|
+
* `basis` field: ONE of the five is measured and four are not, and a checklist that presented all
|
|
374
|
+
* five in the same confident voice would be inventing four Salesforce behaviours to sound complete.
|
|
375
|
+
*
|
|
376
|
+
* The frame that makes the unmeasured four honest AND useful: while a field sits deleted it is
|
|
377
|
+
* unreachable — `FieldDefinition`, `EntityParticle`, `describe` and `readMetadata` are all blind to
|
|
378
|
+
* it and its data is not SOQL-queryable (2026-08-02-005 §2, §5). So nothing could have checked any
|
|
379
|
+
* of these in the window, by anyone, and "verify it" is not hedging — it is the only available
|
|
380
|
+
* answer. The verification wave (`mode: "verify"`) then does the mechanical half against the wave's
|
|
381
|
+
* own pre-image, which is the comparison AE4 asks for.
|
|
382
|
+
*/
|
|
383
|
+
export const POST_UNDELETE_CHECKLIST = [
|
|
384
|
+
{
|
|
385
|
+
title: "STRIP THE `_del` SUFFIX FROM THE API NAME",
|
|
386
|
+
basis: "MEASURED",
|
|
387
|
+
detail: "Salesforce renamed the field when it was deleted — `Region__c` became `Region_del__c` — and it comes back under that name, " +
|
|
388
|
+
"so every formula, flow, validation rule, layout, report and integration that names the original is still broken after a successful undelete. " +
|
|
389
|
+
"Two traps, both measured: the LABEL was never renamed and does not track the API name, so the field looks right in Setup while the API name is wrong; " +
|
|
390
|
+
"and the original API name was FREED by the delete, so if anything has taken it since, the rename is refused until that is resolved. " +
|
|
391
|
+
"A second deleted generation takes a numeric suffix (`_del1`), and both generations can sit in the Deleted Fields list at once holding different data."
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
title: "RE-CHECK `required` AND `unique`",
|
|
395
|
+
basis: "VERIFY",
|
|
396
|
+
detail: "Compare the restored field against the pre-image this rollback holds. Nothing in the deleted window could have told you whether these survive — " +
|
|
397
|
+
"a deleted field is invisible to describe and FieldDefinition and its data is not queryable — so this is a look, not a formality. " +
|
|
398
|
+
"`required` in particular changes data entry for every user of the object, in both directions."
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
title: "RE-CHECK FIELD DEPENDENCIES",
|
|
402
|
+
basis: "VERIFY",
|
|
403
|
+
detail: "A controlling/dependent picklist pair is stored on the DEPENDENT field. If the undeleted field was either half of one, confirm the dependency is back " +
|
|
404
|
+
"and that the value mapping is the one the pre-image records — a dependency that came back empty enforces nothing and reports no error."
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
title: "RE-CHECK A MASTER-DETAIL RELATIONSHIP",
|
|
408
|
+
basis: "VERIFY",
|
|
409
|
+
detail: "A master-detail and a lookup are not interchangeable: roll-up summaries, record ownership, sharing and cascade delete all ride on master-detail. " +
|
|
410
|
+
"If the field was a relationship, confirm it came back as the SAME kind, and that any roll-up summary fields on the parent are recalculating."
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
title: "RE-ADD IT TO PAGE LAYOUTS AND COMPACT LAYOUTS",
|
|
414
|
+
basis: "VERIFY",
|
|
415
|
+
detail: "Deleting a field removes it from every layout that carried it, and layout membership is a property of the LAYOUT, not of the field — " +
|
|
416
|
+
"so restoring the field cannot restore it. Until this is done the data is back and nobody can see it. " +
|
|
417
|
+
"The wave's pre-image for the affected layouts is on this machine and is what to compare against."
|
|
418
|
+
}
|
|
419
|
+
];
|
|
420
|
+
/**
|
|
421
|
+
* The card for one deleted field the operator has to handle in Setup: path, window, and checklist.
|
|
422
|
+
*
|
|
423
|
+
* NEVER A DEPLOY, and the card says so first. The failure this exists to prevent is a rollback that
|
|
424
|
+
* "succeeds" by re-creating the field from its pre-image: the definition returns, the deploy reports
|
|
425
|
+
* green, and every value every record held is gone — a second incident caused by the recovery.
|
|
426
|
+
*
|
|
427
|
+
* TWO CARDS IN ONE, because two states reach `OPERATOR_ACTION` and they ask for different things.
|
|
428
|
+
* The in-window field is KNOWN to be sitting in Setup's Deleted Fields list, and the instruction is
|
|
429
|
+
* to undelete it. A field whose window is out on a clock NOTHING CHECKED (`isUnverifiedLapse`) is a
|
|
430
|
+
* question, and the instruction is to go and settle it — because either answer is possible and the
|
|
431
|
+
* two have opposite consequences. Printing the plain undelete card over that state would promise
|
|
432
|
+
* recoverable data nobody verified, which is this module's own defect wearing the other hat; and
|
|
433
|
+
* this card is the ONLY per-component detail an `OPERATOR_ACTION` entry gets, since `renderPlan`
|
|
434
|
+
* prints the disposition line for every other kind.
|
|
435
|
+
*
|
|
436
|
+
* AND IT CARRIES THE DIVERGENCE, which is not decoration. `renderPlan` prints per-component detail
|
|
437
|
+
* for every disposition EXCEPT `OPERATOR_ACTION`, whose whole detail is this card — so a divergence
|
|
438
|
+
* that reached only the disposition list would be dropped silently for exactly the components where
|
|
439
|
+
* it matters most. For an in-window delete the finding has one meaning and it is checklist item 1's
|
|
440
|
+
* measured trap: the wave's recorded post-image is ABSENCE, so a divergence at all means something
|
|
441
|
+
* now holds the API name, and the rename back off `_del` is refused until that is resolved. That is
|
|
442
|
+
* the fact that decides whether the operator's undelete actually finishes.
|
|
443
|
+
*/
|
|
444
|
+
export function renderUndeleteCard(component, target) {
|
|
445
|
+
const objectName = component.fullName.split(".")[0] ?? "";
|
|
446
|
+
const window = component.window;
|
|
447
|
+
const candidates = window?.candidates ?? [];
|
|
448
|
+
const divergence = component.divergence;
|
|
449
|
+
const setupPath = `Object Manager → ${objectName} → Fields & Relationships → Deleted Fields → Undelete`;
|
|
450
|
+
// The clock said the window ran out and nothing checked the org against it. Asked of the SAME
|
|
451
|
+
// predicate the classifier gated on, so the card cannot describe a state the gate did not produce.
|
|
452
|
+
const unverifiedLapse = isUnverifiedLapse(component);
|
|
453
|
+
const anchor = window?.basis === "BUNDLE_WINDOW" ? "measured from this wave's own mutation window" : "INFERRED from SetupAuditTrail, joined by field label";
|
|
454
|
+
// `daysRemaining` IS A FLOOR, AND A FLOOR IS NOT AN ELAPSED DAY. `-1` covers everything from one
|
|
455
|
+
// minute past the derived date to a full day past it, so `-(daysRemaining)` printed "1 day(s) ago"
|
|
456
|
+
// over a lapse an hour and a half old — overstating, in the one card whose entire subject is that
|
|
457
|
+
// nothing verified the lapse at all. What the floor DOES establish is a lower bound, so that is
|
|
458
|
+
// what this says: `-1` means less than a day, and `-n` means more than `n - 1` whole days.
|
|
459
|
+
const lapsedAgo = window?.daysRemaining !== null && window?.daysRemaining !== undefined && window.daysRemaining < -1
|
|
460
|
+
? `more than ${-window.daysRemaining - 1} day(s) ago`
|
|
461
|
+
: "less than a day ago";
|
|
462
|
+
const windowLine = window === undefined || window.basis === "UNVERIFIED"
|
|
463
|
+
? "Window: NOT ESTABLISHED from this bundle — Setup's own Deleted Fields list is what shows whether it is still there and for how long."
|
|
464
|
+
: unverifiedLapse
|
|
465
|
+
? `Window: the derived window CLOSED ${window.lapsesAt} (${anchor}) — ${lapsedAgo} BY THE CLOCK, which is the only thing that said so.`
|
|
466
|
+
: `Window: ${window.daysRemaining} day(s) left, closes ${window.lapsesAt} (${anchor}).`;
|
|
467
|
+
const lines = [
|
|
468
|
+
// TITLED FOR WHAT IT ASKS FOR, not for the outcome it cannot promise. The legend and the
|
|
469
|
+
// checklist both stopped stating the undelete as a given ("settled in Setup", "IF YOU FIND IT AND
|
|
470
|
+
// UNDELETE") on the grounds that either answer is possible here; a headline that still announced
|
|
471
|
+
// the conclusion is the same overclaim in the largest type on the card.
|
|
472
|
+
unverifiedLapse ? `SETTLE IN SETUP — ${component.key}` : `UNDELETE — ${component.key}`,
|
|
473
|
+
"",
|
|
474
|
+
" DO NOT RE-CREATE THIS FIELD AS METADATA. The undelete restores the field AND ITS DATA; a",
|
|
475
|
+
" re-create from the pre-image restores the definition only, reports success, and throws every",
|
|
476
|
+
" value every record held away. That is why this component is not part of the deploy below.",
|
|
477
|
+
...(unverifiedLapse
|
|
478
|
+
? [
|
|
479
|
+
"",
|
|
480
|
+
" [!] THE WINDOW LOOKS CLOSED, AND NOTHING VERIFIED IT. No live probe answered whether",
|
|
481
|
+
" Salesforce still lists this field as deleted-and-recoverable, so the line below is a",
|
|
482
|
+
" derivation from recorded timestamps, not a reading of the org. This tool will not",
|
|
483
|
+
" re-create the field on that: no acknowledgement releases it, because if the field IS",
|
|
484
|
+
" still listed the re-create destroys exactly the data an undelete would have restored.",
|
|
485
|
+
"",
|
|
486
|
+
" SETTLE IT — open the Setup path below and look:",
|
|
487
|
+
" • If the field IS listed, UNDELETE IT. The data is intact and this window was wrong.",
|
|
488
|
+
" • If it is NOT listed, the field and its data are gone. Re-run this rollback with the",
|
|
489
|
+
" org reachable so the probe can measure that, and the re-create is then offered for",
|
|
490
|
+
" your acknowledgement:",
|
|
491
|
+
` direct_rollback {sessionId: "${target.sessionId}", wave: "${target.wave}", mode: "plan"}`
|
|
492
|
+
]
|
|
493
|
+
: []),
|
|
494
|
+
"",
|
|
495
|
+
` Setup path: ${setupPath}`,
|
|
496
|
+
// THE DELETED NAME IS DATED WHEN NOTHING RE-READ IT — and never dropped.
|
|
497
|
+
//
|
|
498
|
+
// `window.candidates` is only ever written by a probe that ANSWERED (`matchDeletedField` over a
|
|
499
|
+
// live Tooling read), so on an unverified lapse it can only have arrived one way: this plan's
|
|
500
|
+
// earlier pass measured it, and `recheckUndeleteWindow` carried it forward because it had nothing
|
|
501
|
+
// fresher (see its `candidates: presenceVerified ? candidates : component.window.candidates`).
|
|
502
|
+
// Printed unattributed it read as a present-tense reading of the org, three lines under "nothing
|
|
503
|
+
// verified it" — the card contradicting itself in the operator's hands.
|
|
504
|
+
//
|
|
505
|
+
// ATTRIBUTED RATHER THAN REMOVED, because it is the one string that FINDS the field: Salesforce
|
|
506
|
+
// renames a field on the way out, so an operator sent to Deleted Fields without the `_del` name
|
|
507
|
+
// is searching for something Setup does not show under the name they were given.
|
|
508
|
+
...(candidates[0]
|
|
509
|
+
? unverifiedLapse
|
|
510
|
+
? [
|
|
511
|
+
` This plan's EARLIER reading — taken before the probe stopped answering — listed it under`,
|
|
512
|
+
` its DELETED name: ${candidates[0]}. SEARCH FOR THAT in Setup; nothing has re-read it since.`
|
|
513
|
+
]
|
|
514
|
+
: [` Salesforce lists it under its DELETED name: ${candidates[0]}`]
|
|
515
|
+
: []),
|
|
516
|
+
` ${windowLine}`,
|
|
517
|
+
...(window?.presenceVerified === false
|
|
518
|
+
? [" Presence in the Deleted Fields list is UNVERIFIED — no live probe answered. Not the same as absent."]
|
|
519
|
+
: []),
|
|
520
|
+
...(candidates.length > 1
|
|
521
|
+
? [
|
|
522
|
+
// THE TWIN OF THE LINE ABOVE, off the same array and dated the same way. "N generations
|
|
523
|
+
// coexist" is a reading of Setup's Deleted Fields list, and on an unverified lapse nothing
|
|
524
|
+
// in this pass read it — the sentence sat four lines under "no live probe answered". The
|
|
525
|
+
// NAMES stay either way: the choice between two generations is the operator's to make in
|
|
526
|
+
// Setup, and they cannot compare deletion dates without knowing which rows to compare.
|
|
527
|
+
unverifiedLapse
|
|
528
|
+
? ` [!] that EARLIER reading listed ${candidates.length} deleted generations of this API name: ${candidates.join(", ")}.`
|
|
529
|
+
: ` [!] ${candidates.length} deleted generations of this API name coexist: ${candidates.join(", ")}.`,
|
|
530
|
+
" Salesforce suffixes a re-delete (`_del`, `_del1`, …) and both hold DIFFERENT data. Which one",
|
|
531
|
+
" this wave deleted cannot be decided from the API — compare the deletion dates Setup shows",
|
|
532
|
+
" against this wave's own window before you restore one."
|
|
533
|
+
]
|
|
534
|
+
: []),
|
|
535
|
+
...(divergence
|
|
536
|
+
? [
|
|
537
|
+
"",
|
|
538
|
+
` [!] THE API NAME ${component.fullName} IS OCCUPIED. The R16 preflight compared live state`,
|
|
539
|
+
` against what wave ${target.wave} recorded — a DELETE, so the record is ABSENCE — and found something`,
|
|
540
|
+
" there. What it found:",
|
|
541
|
+
...wrap(divergence.detail, 90).map((sentence) => ` ${sentence}`),
|
|
542
|
+
...(divergence.laterWaves?.length
|
|
543
|
+
? [` This org's own later delivery accounts for it: ${divergence.laterWaves.join(", ")}.`]
|
|
544
|
+
: []),
|
|
545
|
+
" THIS IS WHAT BLOCKS CHECKLIST ITEM 1 BELOW. The delete FREED the original name (measured), so",
|
|
546
|
+
" renaming the restored field back off its `_del` suffix is refused while anything else holds it.",
|
|
547
|
+
" Decide what happens to the occupant BEFORE you undelete — and note that the occupant is a",
|
|
548
|
+
" DIFFERENT field: it holds none of the data the deleted generation is still holding for you.",
|
|
549
|
+
" Nothing in this tool will deploy over it; that is the operator's decision to make in Setup."
|
|
550
|
+
]
|
|
551
|
+
: []),
|
|
552
|
+
"",
|
|
553
|
+
// The checklist applies the moment the field comes back, which is exactly as certain as the
|
|
554
|
+
// undelete is. "AFTER YOU UNDELETE" over a field nobody has found yet states the undelete as a
|
|
555
|
+
// given — the same overclaim the block above exists to refuse, restated in a heading.
|
|
556
|
+
unverifiedLapse
|
|
557
|
+
? " IF YOU FIND IT AND UNDELETE — five checks, none of which the undelete performs for you:"
|
|
558
|
+
: " AFTER YOU UNDELETE — five checks, none of which the undelete performs for you:"
|
|
559
|
+
];
|
|
560
|
+
POST_UNDELETE_CHECKLIST.forEach((check, index) => {
|
|
561
|
+
lines.push(` ${index + 1}. ${check.title} [${check.basis === "MEASURED" ? "measured" : "verify — not measured"}]`);
|
|
562
|
+
for (const sentence of wrap(check.detail, 96))
|
|
563
|
+
lines.push(` ${sentence}`);
|
|
564
|
+
if (index === 0 && candidates[0]) {
|
|
565
|
+
lines.push(` For this field: rename ${candidates[0]} back to ${component.fullName}.`);
|
|
566
|
+
}
|
|
567
|
+
if (index === 0 && divergence) {
|
|
568
|
+
lines.push(` BLOCKED AS THINGS STAND — ${component.fullName} is occupied; see the finding above.`);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
lines.push("", ` Then run the verification wave, which compares the restored field against wave ${target.wave}'s own`, " pre-image and reports what the undelete did not restore:", ` direct_rollback {sessionId: "${target.sessionId}", wave: "${target.wave}", mode: "verify"}`);
|
|
572
|
+
return {
|
|
573
|
+
key: component.key,
|
|
574
|
+
objectName,
|
|
575
|
+
...(candidates[0] ? { deletedName: candidates[0] } : {}),
|
|
576
|
+
candidates,
|
|
577
|
+
setupPath,
|
|
578
|
+
window: windowLine,
|
|
579
|
+
...(divergence ? { divergence: divergence.detail } : {}),
|
|
580
|
+
text: lines.join("\n")
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
function wrap(text, width) {
|
|
584
|
+
const out = [];
|
|
585
|
+
let line = "";
|
|
586
|
+
for (const word of text.split(/\s+/)) {
|
|
587
|
+
if (line.length > 0 && line.length + word.length + 1 > width) {
|
|
588
|
+
out.push(line);
|
|
589
|
+
line = word;
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
line = line.length > 0 ? `${line} ${word}` : word;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (line.length > 0)
|
|
596
|
+
out.push(line);
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
export const UNDELETE_SCHEDULE_FILE = "undelete-verification.json";
|
|
600
|
+
/**
|
|
601
|
+
* Turn the executable half of a plan into a wave.
|
|
602
|
+
*
|
|
603
|
+
* The bytes come from the target bundle's `pre/source/` tree — the deployable image a retrieve
|
|
604
|
+
* landed before the wave ran — walked with the SAME walker `direct_apply` uses on an authored tree,
|
|
605
|
+
* so a rollback deploys through the identical path as any other wave. Filtered to the selection
|
|
606
|
+
* rather than deployed wholesale: `pre/source/` holds an image for every component the wave
|
|
607
|
+
* touched, and a partial rollback that quietly redeployed all of them would undo work the operator
|
|
608
|
+
* did not select.
|
|
609
|
+
*/
|
|
610
|
+
export async function rollbackWork(bundleDir, selected) {
|
|
611
|
+
// AE4 AT THE CHOKEPOINT — layer 2 of three, and the last one.
|
|
612
|
+
//
|
|
613
|
+
// This function is the ONLY producer of the edits and deletes `runDirectApply` sends, and
|
|
614
|
+
// `runDirectApply` is called exactly once in this module. So this is the single line every
|
|
615
|
+
// component must cross to reach a deploy, and it is the right place to state the invariant as a
|
|
616
|
+
// fact about the system rather than as a property of whoever called it.
|
|
617
|
+
//
|
|
618
|
+
// THROWS RATHER THAN FILTERS, deliberately. Silently dropping the component would let a rollback
|
|
619
|
+
// report a clean partial success while an operator who believed they had released it went looking
|
|
620
|
+
// for a field that never came back — and it would hide a gating bug, which is exactly the failure
|
|
621
|
+
// this guard exists to catch. Nothing downstream of a correct `gateRollback` can trip this; if it
|
|
622
|
+
// ever fires, the gating is wrong and the loud stop is the point.
|
|
623
|
+
const inWindow = selected.filter((component) => component.class === "UNDELETE_WINDOW");
|
|
624
|
+
if (inWindow.length > 0) {
|
|
625
|
+
throw new Error(`AE4 VIOLATION — refusing to build a deploy containing ${inWindow.length} component(s) still inside Salesforce's ` +
|
|
626
|
+
`undelete window: ${inWindow.map((component) => component.key).join(", ")}. An UNDELETE_WINDOW component is ` +
|
|
627
|
+
`recoverable WITH ITS DATA through Setup (Object Manager → Fields & Relationships → Deleted Fields → Undelete) ` +
|
|
628
|
+
`and may NEVER join a deploy: re-creating it from the pre-image restores the definition only, reports success, ` +
|
|
629
|
+
`and destroys every value every record held while the recoverable generation is still sitting in Setup. ` +
|
|
630
|
+
`No acknowledgement releases this — the ack vocabulary cannot buy a re-create, because the re-create is the harm. ` +
|
|
631
|
+
`Reaching this line means the gating that should have carded these components as OPERATOR_ACTION did not; that is ` +
|
|
632
|
+
`a defect in this tool, not something to work around.`);
|
|
633
|
+
}
|
|
634
|
+
// THE SAME INVARIANT WITH THE CLOCK AS ITS ONLY WITNESS — and it asks `rollback.ts`'s own predicate
|
|
635
|
+
// rather than re-deriving the question, because a guard that re-derives is a guard that can come to
|
|
636
|
+
// disagree with the rule it guards. (That is not hypothetical here: U9's divergence handling
|
|
637
|
+
// rewrote a gate the AE4 check above was reading, and the fix was to ask the CLASS instead.)
|
|
638
|
+
//
|
|
639
|
+
// A `LOSSY_RECREATE` reached by a probe that answered is a component Salesforce told us is past
|
|
640
|
+
// recovery, and R20's per-component ack is the right release for it. One reached by a probe that
|
|
641
|
+
// did NOT answer is a component nobody looked at, and the class alone cannot tell the two apart.
|
|
642
|
+
const unverified = selected.filter(isUnverifiedLapse);
|
|
643
|
+
if (unverified.length > 0) {
|
|
644
|
+
throw new Error(`AE4 VIOLATION — refusing to build a deploy containing ${unverified.length} component(s) whose undelete window ` +
|
|
645
|
+
`is UNVERIFIED: ${unverified.map((component) => component.key).join(", ")}. Nothing read Salesforce's Deleted ` +
|
|
646
|
+
`Fields list for ${plural(unverified.length, "this component", "these components")}, so "the window has closed" ` +
|
|
647
|
+
`is a clock's inference and not the org's answer — and if the field is still listed, re-creating it from the ` +
|
|
648
|
+
`pre-image destroys the data an undelete would have restored, while the deploy reports success. These must be ` +
|
|
649
|
+
`carded as OPERATOR_ACTION so the operator settles it in Setup, or re-classified against a reachable org. ` +
|
|
650
|
+
`Reaching this line means the gating did not; that is a defect in this tool, not something to work around.`);
|
|
651
|
+
}
|
|
652
|
+
const deletes = [];
|
|
653
|
+
const needsImage = [];
|
|
654
|
+
for (const component of selected) {
|
|
655
|
+
// Undoing a CREATE is a delete; everything else executable is a redeploy of the pre-image.
|
|
656
|
+
if (component.class === "CLEAN_DELETE")
|
|
657
|
+
deletes.push({ typeName: component.typeName, fullName: component.fullName });
|
|
658
|
+
else
|
|
659
|
+
needsImage.push(component);
|
|
660
|
+
}
|
|
661
|
+
const edits = [];
|
|
662
|
+
const missingImage = [];
|
|
663
|
+
if (needsImage.length > 0) {
|
|
664
|
+
const walked = await walkSourceTree(path.join(bundleDir, "pre", "source")).catch(() => ({ components: [], skipped: [] }));
|
|
665
|
+
const byKey = new Map(walked.components.map((edit) => [componentKey(edit), edit]));
|
|
666
|
+
for (const component of needsImage) {
|
|
667
|
+
const edit = byKey.get(component.key);
|
|
668
|
+
if (edit)
|
|
669
|
+
edits.push(edit);
|
|
670
|
+
else
|
|
671
|
+
missingImage.push(component.key);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return { edits, deletes, missingImage };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* `attempt.json` exists but this machine could not learn a trustworthy attempt number from it —
|
|
678
|
+
* truncated JSON, or an `"attempt"` field that is not a positive integer (`{"attempt":"3"}` and
|
|
679
|
+
* `{"attempt":0}` included: both are shapes `nextWaveAttempt` never writes, and coercing or
|
|
680
|
+
* defaulting either would trust exactly what cannot be trusted).
|
|
681
|
+
*
|
|
682
|
+
* REFUSES rather than falling back to `attempt = 1` (single-attempt, `preRoot = bundleDir`), because
|
|
683
|
+
* that fallback is exactly the failure `resolveRollbackBaseline` exists to prevent: a marker this
|
|
684
|
+
* corrupt could belong to a bundle retried five times, and defaulting it to "never retried" would
|
|
685
|
+
* silently redeploy the mid-wave `pre/` image the whole function exists to keep out of a rollback.
|
|
686
|
+
* A missing marker (`ENOENT`) is different and NOT this case — it is handled by the caller before
|
|
687
|
+
* this is ever reached, because a bundle from before attempt tracking existed is, by construction,
|
|
688
|
+
* single-attempt.
|
|
689
|
+
*/
|
|
690
|
+
function unreadableMarkerRefusal(error) {
|
|
691
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
692
|
+
return {
|
|
693
|
+
ok: false,
|
|
694
|
+
code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
|
|
695
|
+
message: `this bundle's attempt.json exists but is unreadable (${detail}) — the attempt marker is unreadable, so the bundle's retry ` +
|
|
696
|
+
`history cannot be trusted: this machine cannot tell a single-attempt bundle from a retried one, or find attempt 1's ` +
|
|
697
|
+
`archived baseline if it was retried. Deploying its pre/ on the assumption that it was never retried could redeploy the ` +
|
|
698
|
+
`org mid-wave and report a clean rollback. Use direct_history to see what this wave actually delivered.`
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Which directory the rollback's pre-image reads come from.
|
|
703
|
+
*
|
|
704
|
+
* The bundle dir is keyed by wave label; attempts share it. Since archivePriorAttempt (tools/
|
|
705
|
+
* direct.ts), attempts/<n> holds each prior attempt's evidence, and the EARLIEST archived attempt
|
|
706
|
+
* that captured a pre-image holds the baseline from before the wave first touched the org — the
|
|
707
|
+
* only state a rollback of the wave can honestly restore, because a later attempt's "before" is an
|
|
708
|
+
* earlier attempt's "after". An archived attempt whose manifest proves it wrote nothing is a no-op
|
|
709
|
+
* that never reached the org and is skipped, not fatal. A multi-attempt bundle with no completed
|
|
710
|
+
* archive predates that fix (or the copy died): its pre/ records the org DURING the wave, and
|
|
711
|
+
* deploying it would report a clean rollback while leaving earlier attempts' changes in place — so
|
|
712
|
+
* that case refuses rather than guesses. The SAME refusal covers a marker this
|
|
713
|
+
* machine cannot read at all: an unparseable or mis-shaped `attempt.json` on a genuinely retried
|
|
714
|
+
* bundle is indistinguishable from corruption, and defaulting it to "single attempt" would redeploy
|
|
715
|
+
* the mid-wave image exactly as confidently as no check at all (see `unreadableMarkerRefusal`).
|
|
716
|
+
*/
|
|
717
|
+
export async function resolveRollbackBaseline(bundleDir) {
|
|
718
|
+
let raw;
|
|
719
|
+
try {
|
|
720
|
+
raw = await fs.readFile(path.join(bundleDir, "attempt.json"), "utf8");
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (error?.code === "ENOENT") {
|
|
724
|
+
// No marker at all — a bundle from before attempt tracking is necessarily single-attempt.
|
|
725
|
+
return { ok: true, preRoot: bundleDir };
|
|
726
|
+
}
|
|
727
|
+
// Present in some form (a directory, permission-denied, …) but not readable as a file — the
|
|
728
|
+
// same untrusted-history case as a malformed marker, so the same refusal.
|
|
729
|
+
return unreadableMarkerRefusal(error);
|
|
730
|
+
}
|
|
731
|
+
let attempt;
|
|
732
|
+
try {
|
|
733
|
+
const parsed = JSON.parse(raw);
|
|
734
|
+
// Positive integers only — nextWaveAttempt never writes anything else, so a zero, negative or
|
|
735
|
+
// fractional attempt is corruption wearing a number, and must refuse like any other shape it
|
|
736
|
+
// cannot trust rather than sliding through a `<= 1` comparison as "single attempt".
|
|
737
|
+
if (typeof parsed.attempt !== "number" || !Number.isInteger(parsed.attempt) || parsed.attempt < 1) {
|
|
738
|
+
throw new Error(`"attempt" is ${JSON.stringify(parsed.attempt)}, not a positive integer`);
|
|
739
|
+
}
|
|
740
|
+
attempt = parsed.attempt;
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
return unreadableMarkerRefusal(error);
|
|
744
|
+
}
|
|
745
|
+
if (attempt === 1)
|
|
746
|
+
return { ok: true, preRoot: bundleDir };
|
|
747
|
+
// Retried. The bundle's own pre/ belongs to the LAST attempt; the honest baseline is the
|
|
748
|
+
// EARLIEST attempt that captured one. Walk the archives in order:
|
|
749
|
+
// - a completed archive (archive.json is archivePriorAttempt's completion receipt) that holds
|
|
750
|
+
// a pre-image → that archive is the baseline; nothing earlier can have touched the org.
|
|
751
|
+
// - a completed archive that recorded NO pre-image → a proven no-op: pre/ is written before
|
|
752
|
+
// any deploy, so an attempt that never wrote it died before it could reach the org. Skip it
|
|
753
|
+
// and keep walking.
|
|
754
|
+
// - no archive.json → either the bundle predates per-attempt archiving or the copy never
|
|
755
|
+
// finished; both are the untrusted-history case, and both refuse.
|
|
756
|
+
const noOps = [];
|
|
757
|
+
for (let n = 1; n < attempt; n++) {
|
|
758
|
+
const archived = path.join(bundleDir, "attempts", String(n));
|
|
759
|
+
const manifest = await readJson(path.join(archived, "archive.json"));
|
|
760
|
+
if (!manifest?.archived) {
|
|
761
|
+
return {
|
|
762
|
+
ok: false,
|
|
763
|
+
code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
|
|
764
|
+
message: `this wave was applied ${attempt} times under one label, and attempt ${n} left no completed archive — either the bundle predates per-attempt archiving, or the archive never finished. The bundle's pre/ was recaptured by the last attempt, so it records the org DURING the wave, not before it. ` +
|
|
765
|
+
`Deploying it would report a clean rollback while leaving earlier attempts' changes in place. ` +
|
|
766
|
+
`Use direct_history to see what each attempt delivered; the mirror commit before attempt 1 holds the true baseline, and restoring from it is a new wave, not this rollback.`
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
if (manifest.archived["pre"]) {
|
|
770
|
+
try {
|
|
771
|
+
await fs.access(path.join(archived, "pre", "projection.json"));
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
return {
|
|
775
|
+
ok: false,
|
|
776
|
+
code: "ROLLBACK_PREIMAGE_AMBIGUOUS",
|
|
777
|
+
message: `this wave was applied ${attempt} times under one label, and attempt ${n}'s archive manifest records a pre-image its directory does not hold — a truncated archive cannot testify to a baseline. ` +
|
|
778
|
+
`Use direct_history to see what each attempt delivered; the mirror commit before attempt 1 holds the true baseline, and restoring from it is a new wave, not this rollback.`
|
|
779
|
+
};
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
preRoot: archived,
|
|
784
|
+
note: `this wave was applied ${attempt} times under one label; the rollback restores the baseline attempt ${n} captured, from before the wave first changed the org.` +
|
|
785
|
+
(noOps.length > 0 ? ` (Attempt${noOps.length > 1 ? "s" : ""} ${noOps.join(", ")} left no pre-image and never reached the org.)` : "")
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
noOps.push(n);
|
|
789
|
+
}
|
|
790
|
+
// Every earlier attempt is a PROVEN no-op: none wrote a pre-image, so none reached the deploy
|
|
791
|
+
// that follows the pre-write — the org went untouched until the attempt that wrote this bundle's
|
|
792
|
+
// own pre/, which is therefore the wave's honest baseline. Refusing here would permanently lock
|
|
793
|
+
// rollback out of the ordinary "first attempt errored out, fix it, re-run the label" flow.
|
|
794
|
+
return {
|
|
795
|
+
ok: true,
|
|
796
|
+
preRoot: bundleDir,
|
|
797
|
+
note: `this wave was applied ${attempt} times under one label, but attempt${noOps.length > 1 ? "s" : ""} ${noOps.join(", ")} left no pre-image and never reached the org — the last attempt's own pre/ is the wave's baseline.`
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
function liveStateReport(state, readerError) {
|
|
801
|
+
if (!state) {
|
|
802
|
+
return { status: "NOT_ATTEMPTED", scope: 0, count: 0, error: readerError ?? "no live org reader", unknown: [] };
|
|
803
|
+
}
|
|
804
|
+
const count = state.live.size;
|
|
805
|
+
const status = state.error !== undefined
|
|
806
|
+
? "FAILED"
|
|
807
|
+
: state.scope === 0
|
|
808
|
+
? "NOTHING_IN_SCOPE"
|
|
809
|
+
: count === 0
|
|
810
|
+
? "NONE_READABLE"
|
|
811
|
+
: count < state.scope
|
|
812
|
+
? "PARTIAL"
|
|
813
|
+
: "READ";
|
|
814
|
+
return { status, scope: state.scope, count, ...(state.error ? { error: state.error } : {}), unknown: state.unknown };
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Classify, gate, card — and, in `execute`, deploy.
|
|
818
|
+
*
|
|
819
|
+
* TWO CLASSIFICATION PASSES, and the first one touches nothing. The classifier needs the live map
|
|
820
|
+
* and the probe up front, and which components to read live is decided by the wave's own candidate
|
|
821
|
+
* list, which only the classifier knows. So the first pass runs against the bundle alone (pure
|
|
822
|
+
* disk, no org contact) to learn the candidates, and the second — with live state and the probe —
|
|
823
|
+
* is the plan. Both are handed the same `asOf`, so the window arithmetic cannot move between them.
|
|
824
|
+
*/
|
|
825
|
+
export async function runRollback(input) {
|
|
826
|
+
const asOf = input.now ?? new Date().toISOString();
|
|
827
|
+
const notes = [];
|
|
828
|
+
if (input.mode === "verify")
|
|
829
|
+
return verifyUndelete(input, asOf);
|
|
830
|
+
// Pass 1 — the candidate list, from the bundle only.
|
|
831
|
+
const candidates = await classifyRollback({
|
|
832
|
+
bundleDir: input.bundleDir,
|
|
833
|
+
orgId: input.orgId,
|
|
834
|
+
asOf,
|
|
835
|
+
...(input.components ? { components: input.components } : {}),
|
|
836
|
+
...(input.preImageRoot ? { preImageRoot: input.preImageRoot } : {})
|
|
837
|
+
});
|
|
838
|
+
// Live state, for the R16 preflight and for nothing else. It is opt-in in the classifier and
|
|
839
|
+
// SILENT when omitted, so the one thing this must never do is skip it quietly.
|
|
840
|
+
// UNDEFINED when there was no reader, rather than a hand-built result carrying an `error` — a read
|
|
841
|
+
// that never happened is not a read that failed, and shaping the two identically is what forced
|
|
842
|
+
// the distinction to travel beside the value as a boolean the caller had to keep in sync.
|
|
843
|
+
let liveState;
|
|
844
|
+
if (input.reader) {
|
|
845
|
+
liveState = await readLiveState({
|
|
846
|
+
reader: input.reader,
|
|
847
|
+
orgAlias: input.orgAlias,
|
|
848
|
+
apiVersion: input.apiVersion,
|
|
849
|
+
outDir: path.join(input.rollbackBundleDir, "preflight"),
|
|
850
|
+
components: candidates.components.map((c) => ({ typeName: c.typeName, fullName: c.fullName }))
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
const probe = input.reader ? liveRollbackProbe(input.reader) : undefined;
|
|
854
|
+
// Pass 2 — the plan.
|
|
855
|
+
let plan = await classifyRollback({
|
|
856
|
+
bundleDir: input.bundleDir,
|
|
857
|
+
orgId: input.orgId,
|
|
858
|
+
asOf,
|
|
859
|
+
...(input.components ? { components: input.components } : {}),
|
|
860
|
+
...(probe ? { probe } : {}),
|
|
861
|
+
...(liveState && liveState.live.size > 0 ? { live: liveState.live } : {}),
|
|
862
|
+
...(input.preImageRoot ? { preImageRoot: input.preImageRoot } : {})
|
|
863
|
+
});
|
|
864
|
+
// THE WINDOW IS RE-ASKED AT EXECUTION TIME, per component. A plan that said "11 days left,
|
|
865
|
+
// undelete in Setup" is a statement about the instant it was made; an operator who takes it to a
|
|
866
|
+
// change board and comes back on day 16 is holding a promise Salesforce has already broken.
|
|
867
|
+
let reclassified = [];
|
|
868
|
+
if (input.mode === "execute") {
|
|
869
|
+
const rechecked = await recheckUndeleteWindow({ plan, asOf, ...(probe ? { probe } : {}) });
|
|
870
|
+
plan = rechecked.plan;
|
|
871
|
+
reclassified = rechecked.reclassified;
|
|
872
|
+
}
|
|
873
|
+
// AN ACK THAT MATCHED NOTHING IS REPORTED, never dropped. `classifyRollback` already does this for
|
|
874
|
+
// an unmatched `components` entry, and the asymmetry was the bug: a mistyped `ackComponents` key
|
|
875
|
+
// silently releases nothing, so the operator reads "HELD — needs a named ack" against a component
|
|
876
|
+
// they believe they just released, and the natural next move is to look for what else is wrong
|
|
877
|
+
// rather than at the key. Named here in the same voice, and it does not block anything: the keys
|
|
878
|
+
// that DID match still release their components.
|
|
879
|
+
const knownKeys = new Set(plan.components.map((component) => component.key));
|
|
880
|
+
const unmatchedAcks = [...new Set(input.ackComponents ?? [])].filter((key) => !knownKeys.has(key)).sort();
|
|
881
|
+
if (unmatchedAcks.length > 0) {
|
|
882
|
+
notes.push(`acknowledged but not classified in this plan, so ${unmatchedAcks.length > 1 ? "these acks released" : "this ack released"} nothing: ${unmatchedAcks.join(", ")}. ` +
|
|
883
|
+
`An ack key is the component's own \`Type:FullName\`, exactly as printed above` +
|
|
884
|
+
`${input.components ? " — and note this call narrowed the plan with `components`, so a real key outside that selection is not in it either" : ""}.`);
|
|
885
|
+
}
|
|
886
|
+
// A component the preflight could not answer for gates like NOT_COMPARABLE: held behind its own
|
|
887
|
+
// key. A WHOLE-preflight failure holds every component the same way: `liveState.error` set means
|
|
888
|
+
// the projection itself threw — expired token, scope too large — and exactly none of the plan's
|
|
889
|
+
// components were compared against live state. The old reading made that the ONE case with an
|
|
890
|
+
// empty hold-set, so a dead token at execute time released every PROCEEDS component to overwrite
|
|
891
|
+
// live state nobody looked at, while a single unreadable component would have been held (gap G2,
|
|
892
|
+
// confirmed 2026-08-18). The header still prints the failure; the hold is what makes it binding.
|
|
893
|
+
// `liveState` UNDEFINED stays distinct: no reader was supplied at all, which plan mode reports as
|
|
894
|
+
// `readerError` — a read that never happened is not a read that failed, and the per-component ack
|
|
895
|
+
// text would claim an attempt that was never made.
|
|
896
|
+
const unreadableKeys = liveState === undefined
|
|
897
|
+
? new Set()
|
|
898
|
+
: liveState.error === undefined
|
|
899
|
+
? new Set(liveState.unreadable)
|
|
900
|
+
: new Set(plan.components.map((component) => component.key));
|
|
901
|
+
const gated = gateRollback(plan, input.ackComponents ?? [], unreadableKeys);
|
|
902
|
+
// THE SAME RULE ONE STEP IN: an ack that matched a component NO ACKNOWLEDGEMENT CAN RELEASE.
|
|
903
|
+
//
|
|
904
|
+
// An `OPERATOR_ACTION` component is settled in Setup, and the ack vocabulary deliberately cannot
|
|
905
|
+
// buy its deploy — the deploy is the harm. But the key still MATCHES a real component, so the note
|
|
906
|
+
// above never fires for it, and the operator spends their acknowledgement, reads a card that never
|
|
907
|
+
// mentions it, and goes hunting for the mistake somewhere else. That is precisely the silence the
|
|
908
|
+
// unmatched-ack note exists to end; the fact that this one is inert BY DESIGN is a reason to say
|
|
909
|
+
// so, not a reason to say nothing. Reported only: nothing about the run changes.
|
|
910
|
+
const ackedKeys = new Set(input.ackComponents ?? []);
|
|
911
|
+
const inertAcks = gated
|
|
912
|
+
.filter((entry) => entry.disposition === "OPERATOR_ACTION" && ackedKeys.has(entry.component.key))
|
|
913
|
+
.map((entry) => entry.component.key)
|
|
914
|
+
.sort();
|
|
915
|
+
if (inertAcks.length > 0) {
|
|
916
|
+
notes.push(`acknowledged but settled in Setup rather than by a deploy, so ${inertAcks.length > 1 ? "these acks released" : "this ack released"} nothing: ${inertAcks.join(", ")}. ` +
|
|
917
|
+
`No acknowledgement releases ${plural(inertAcks.length, "this component", "these components")} — what an ack would release is a metadata re-create, ` +
|
|
918
|
+
`and the re-create is the harm this card exists to prevent. Follow the card above instead; it is the whole instruction.`);
|
|
919
|
+
}
|
|
920
|
+
const cards = gated
|
|
921
|
+
.filter((entry) => entry.disposition === "OPERATOR_ACTION")
|
|
922
|
+
.map((entry) => renderUndeleteCard(entry.component, { sessionId: input.sessionId, wave: input.wave }));
|
|
923
|
+
// The verification wave is SCHEDULED when the card is issued, not when the operator remembers.
|
|
924
|
+
// It is written on a plan as well as an execute, because the ordinary undelete flow never calls
|
|
925
|
+
// execute at all: card → the operator undeletes in Setup → verify.
|
|
926
|
+
let schedule;
|
|
927
|
+
if (cards.length > 0) {
|
|
928
|
+
schedule = await writeUndeleteSchedule(input, gated, asOf);
|
|
929
|
+
}
|
|
930
|
+
const result = {
|
|
931
|
+
mode: input.mode,
|
|
932
|
+
sessionId: input.sessionId,
|
|
933
|
+
wave: input.wave,
|
|
934
|
+
orgId: input.orgId,
|
|
935
|
+
asOf,
|
|
936
|
+
plan,
|
|
937
|
+
reclassified,
|
|
938
|
+
dispositions: gated.map((entry) => ({
|
|
939
|
+
key: entry.component.key,
|
|
940
|
+
class: entry.component.class,
|
|
941
|
+
gate: entry.component.gate,
|
|
942
|
+
disposition: entry.disposition,
|
|
943
|
+
...(entry.ackKey ? { ackKey: entry.ackKey } : {}),
|
|
944
|
+
ackReasons: entry.ackReasons
|
|
945
|
+
})),
|
|
946
|
+
cards,
|
|
947
|
+
...(schedule ? { schedule } : {}),
|
|
948
|
+
liveState: liveStateReport(liveState, input.readerError),
|
|
949
|
+
notes,
|
|
950
|
+
text: ""
|
|
951
|
+
};
|
|
952
|
+
if (input.mode === "plan") {
|
|
953
|
+
result.text = renderPlan(input, result, gated);
|
|
954
|
+
return result;
|
|
955
|
+
}
|
|
956
|
+
// EXECUTE. Partial by default: whatever is held, refused or carded simply does not travel.
|
|
957
|
+
const executing = gated.filter((entry) => entry.disposition === "EXECUTE").map((entry) => entry.component);
|
|
958
|
+
const work = await rollbackWork(input.preImageRoot ?? input.bundleDir, executing);
|
|
959
|
+
for (const key of work.missingImage) {
|
|
960
|
+
notes.push(`${key} classified as executable but no file for it survives in ${input.wave}'s pre/source tree — nothing was deployed for it. The retrieve that produces that tree is fail-open, so an image can be absent without the wave having failed.`);
|
|
961
|
+
}
|
|
962
|
+
if (work.edits.length === 0 && work.deletes.length === 0) {
|
|
963
|
+
notes.push("nothing executed: no selected component reached an executable state. Every reason is named above — this is the partial-selection posture working, not a failure.");
|
|
964
|
+
result.text = renderPlan(input, result, gated);
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
if (!input.reader) {
|
|
968
|
+
throw new Error(`cannot execute a rollback without a live org reader (${input.readerError ?? "unavailable"}). Classification above is complete and nothing was changed.`);
|
|
969
|
+
}
|
|
970
|
+
result.applied = await runDirectApply({
|
|
971
|
+
reader: input.reader,
|
|
972
|
+
targetOrg: input.orgAlias,
|
|
973
|
+
apiVersion: input.apiVersion,
|
|
974
|
+
edits: work.edits,
|
|
975
|
+
deletes: work.deletes,
|
|
976
|
+
facetUpdates: [],
|
|
977
|
+
acks: input.ackLints ?? [],
|
|
978
|
+
// R3/R21 — the commit is marked as a revert of the wave it undoes, by (session, wave), which is
|
|
979
|
+
// the same rebuild-stable address this tool was called with. A SHA here would not survive the
|
|
980
|
+
// next materializer change.
|
|
981
|
+
revertOf: { sessionId: input.sessionId, wave: input.wave },
|
|
982
|
+
...(input.batchId ? { batchId: input.batchId } : {}),
|
|
983
|
+
...(input.requirementIds?.length ? { requirementIds: input.requirementIds } : {}),
|
|
984
|
+
...(input.operator ? { operator: input.operator } : {}),
|
|
985
|
+
dryRunOnly: input.dryRunOnly ?? false,
|
|
986
|
+
bundleDir: input.rollbackBundleDir
|
|
987
|
+
});
|
|
988
|
+
result.text = renderPlan(input, result, gated);
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
async function writeUndeleteSchedule(input, gated, asOf) {
|
|
992
|
+
const manifest = await readJson(path.join(input.preImageRoot ?? input.bundleDir, "pre", "projection.json"));
|
|
993
|
+
const fileFor = new Map((manifest?.components ?? [])
|
|
994
|
+
.filter((entry) => typeof entry?.typeName === "string" && typeof entry?.fullName === "string" && typeof entry?.file === "string")
|
|
995
|
+
.map((entry) => [`${entry.typeName}:${entry.fullName}`, entry.file]));
|
|
996
|
+
const schedule = {
|
|
997
|
+
target: { sessionId: input.sessionId, wave: input.wave },
|
|
998
|
+
orgId: input.orgId,
|
|
999
|
+
scheduledAt: asOf,
|
|
1000
|
+
components: gated
|
|
1001
|
+
.filter((entry) => entry.disposition === "OPERATOR_ACTION")
|
|
1002
|
+
.map((entry) => {
|
|
1003
|
+
const preImageFile = fileFor.get(entry.component.key);
|
|
1004
|
+
return {
|
|
1005
|
+
key: entry.component.key,
|
|
1006
|
+
typeName: entry.component.typeName,
|
|
1007
|
+
fullName: entry.component.fullName,
|
|
1008
|
+
deletedNames: entry.component.window?.candidates ?? [],
|
|
1009
|
+
...(preImageFile ? { preImageFile } : {}),
|
|
1010
|
+
...(entry.component.window?.lapsesAt ? { lapsesAt: entry.component.window.lapsesAt } : {})
|
|
1011
|
+
};
|
|
1012
|
+
})
|
|
1013
|
+
};
|
|
1014
|
+
try {
|
|
1015
|
+
await fs.mkdir(input.rollbackBundleDir, { recursive: true });
|
|
1016
|
+
await fs.writeFile(path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE), `${JSON.stringify(schedule, null, 1)}\n`, "utf8");
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
// The schedule is a convenience for the verification wave, not the card. A rollback plan that
|
|
1020
|
+
// could not write a file still cards the operator correctly, and `mode: "verify"` says plainly
|
|
1021
|
+
// when it has nothing to read.
|
|
1022
|
+
return schedule;
|
|
1023
|
+
}
|
|
1024
|
+
return schedule;
|
|
1025
|
+
}
|
|
1026
|
+
async function readJson(file) {
|
|
1027
|
+
try {
|
|
1028
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
1029
|
+
}
|
|
1030
|
+
catch {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* AE4's second half: after the operator undeletes, compare the field against its pre-image.
|
|
1036
|
+
*
|
|
1037
|
+
* READS BOTH NAMES, which is the whole reason this cannot be a normal projection. An undelete
|
|
1038
|
+
* restores the field UNDER ITS DELETED NAME (`Region_del__c`) — measured, 2026-08-02-005 §1 — so a
|
|
1039
|
+
* verification that read only the original API name would report a successful restore as "still
|
|
1040
|
+
* absent" and send the operator to re-create it, which is the exact data-loss path R19 exists to
|
|
1041
|
+
* close. Finding it under the deleted name is a PASS with an outstanding rename, and is reported
|
|
1042
|
+
* that way.
|
|
1043
|
+
*/
|
|
1044
|
+
async function verifyUndelete(input, asOf) {
|
|
1045
|
+
const base = {
|
|
1046
|
+
mode: "verify",
|
|
1047
|
+
sessionId: input.sessionId,
|
|
1048
|
+
wave: input.wave,
|
|
1049
|
+
orgId: input.orgId,
|
|
1050
|
+
asOf,
|
|
1051
|
+
notes: [],
|
|
1052
|
+
text: ""
|
|
1053
|
+
};
|
|
1054
|
+
const schedule = await readJson(path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE));
|
|
1055
|
+
if (!schedule || schedule.components.length === 0) {
|
|
1056
|
+
base.text =
|
|
1057
|
+
`no verification wave is scheduled for wave ${input.wave}. One is written when a rollback cards an in-window ` +
|
|
1058
|
+
`deleted field for undelete — run \`direct_rollback {sessionId, wave}\` first, undelete in Setup, then come back.`;
|
|
1059
|
+
return base;
|
|
1060
|
+
}
|
|
1061
|
+
if (!input.reader) {
|
|
1062
|
+
base.liveState = liveStateReport(undefined, input.readerError);
|
|
1063
|
+
base.text = `cannot verify: no live org reader (${input.readerError ?? "unavailable"}). Nothing was read and nothing is claimed.`;
|
|
1064
|
+
return base;
|
|
1065
|
+
}
|
|
1066
|
+
// Both names for every carded component — see the doc comment.
|
|
1067
|
+
const wanted = [];
|
|
1068
|
+
for (const entry of schedule.components) {
|
|
1069
|
+
wanted.push({ typeName: entry.typeName, fullName: entry.fullName });
|
|
1070
|
+
for (const deleted of entry.deletedNames)
|
|
1071
|
+
wanted.push({ typeName: entry.typeName, fullName: deleted });
|
|
1072
|
+
}
|
|
1073
|
+
const live = await readLiveState({
|
|
1074
|
+
reader: input.reader,
|
|
1075
|
+
orgAlias: input.orgAlias,
|
|
1076
|
+
apiVersion: input.apiVersion,
|
|
1077
|
+
outDir: path.join(input.rollbackBundleDir, "verify"),
|
|
1078
|
+
components: wanted
|
|
1079
|
+
});
|
|
1080
|
+
// Reported in the same shape a plan reports it, so a structured consumer has ONE place to ask what
|
|
1081
|
+
// a live read answered rather than re-counting findings for this mode.
|
|
1082
|
+
base.liveState = liveStateReport(live, input.readerError);
|
|
1083
|
+
if (live.error) {
|
|
1084
|
+
base.text = `cannot verify: the live read failed (${live.error}). Nothing was read and nothing is claimed.`;
|
|
1085
|
+
return base;
|
|
1086
|
+
}
|
|
1087
|
+
const findings = [];
|
|
1088
|
+
for (const entry of schedule.components) {
|
|
1089
|
+
const preImage = entry.preImageFile
|
|
1090
|
+
? await readJson(path.join(input.preImageRoot ?? input.bundleDir, "pre", entry.preImageFile))
|
|
1091
|
+
: null;
|
|
1092
|
+
const before = (preImage?.state?.metadata ?? null);
|
|
1093
|
+
const original = live.live.get(entry.key);
|
|
1094
|
+
const deletedKey = entry.deletedNames.map((name) => `${entry.typeName}:${name}`).find((key) => live.live.get(key));
|
|
1095
|
+
const observed = original ?? (deletedKey ? live.live.get(deletedKey) : undefined);
|
|
1096
|
+
const after = (observed?.state?.metadata ?? null);
|
|
1097
|
+
if (!live.live.has(entry.key) && !deletedKey) {
|
|
1098
|
+
findings.push({
|
|
1099
|
+
key: entry.key,
|
|
1100
|
+
state: "UNREADABLE",
|
|
1101
|
+
differences: [],
|
|
1102
|
+
detail: `${entry.key}: the org could not be read for this component under either name, so nothing is claimed about it.`
|
|
1103
|
+
});
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
if (after === null) {
|
|
1107
|
+
findings.push({
|
|
1108
|
+
key: entry.key,
|
|
1109
|
+
state: "ABSENT",
|
|
1110
|
+
differences: [],
|
|
1111
|
+
detail: `${entry.key} is not in the org under its own name${entry.deletedNames.length ? ` or as ${entry.deletedNames.join(", ")}` : ""}. The undelete has not happened yet, or it restored a different generation. DO NOT re-create it as metadata while the window is open — that trades the data for a definition.`
|
|
1112
|
+
});
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
// NEVER compare across the rename: `fullName` differs by construction while the field is still
|
|
1116
|
+
// `_del`-suffixed, and reporting that as a restored-wrong facet would bury the real ones. The
|
|
1117
|
+
// rename is checklist item 1 and is reported as its own state, not as a facet difference.
|
|
1118
|
+
const differences = diffMetadata(before === null ? null : withoutFullName(before), withoutFullName(after));
|
|
1119
|
+
const restoredUnderDeletedName = original === undefined || original === null;
|
|
1120
|
+
findings.push({
|
|
1121
|
+
key: entry.key,
|
|
1122
|
+
state: restoredUnderDeletedName ? "RESTORED_UNDER_DELETED_NAME" : "RESTORED",
|
|
1123
|
+
...(restoredUnderDeletedName && deletedKey ? { liveAs: deletedKey.slice(deletedKey.indexOf(":") + 1) } : {}),
|
|
1124
|
+
differences,
|
|
1125
|
+
detail: restoredUnderDeletedName
|
|
1126
|
+
? `${entry.key} is BACK, still under its deleted name — checklist item 1 is outstanding: rename it to ${entry.fullName} before anything that references it works again.`
|
|
1127
|
+
: before === null
|
|
1128
|
+
? `${entry.key} is live under its own name. This bundle holds no pre-image snapshot for it, so what it looked like before the delete cannot be compared here.`
|
|
1129
|
+
: differences.length === 0
|
|
1130
|
+
? `${entry.key} is live under its own name and matches wave ${input.wave}'s pre-image on every facet the read covers.`
|
|
1131
|
+
: `${entry.key} is live under its own name and differs from wave ${input.wave}'s pre-image on ${differences.length} facet(s) — these are what the undelete did not restore.`
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
base.verification = { target: { sessionId: input.sessionId, wave: input.wave }, checkedAt: asOf, findings };
|
|
1135
|
+
base.text = renderVerification(base.verification, input.wave);
|
|
1136
|
+
return base;
|
|
1137
|
+
}
|
|
1138
|
+
const withoutFullName = (metadata) => {
|
|
1139
|
+
const { fullName: _fullName, ...rest } = metadata;
|
|
1140
|
+
return rest;
|
|
1141
|
+
};
|
|
1142
|
+
function renderVerification(report, wave) {
|
|
1143
|
+
// SAME RULE AS THE PLAN HEADER. `readLiveState` returns without an error when the projection
|
|
1144
|
+
// itself ran and every component inside it failed, so "checked against that wave's own pre-image"
|
|
1145
|
+
// is a heading that survives a verification which read nothing. Each finding says UNREADABLE on
|
|
1146
|
+
// its own line; the summary has to say it too, because the summary is what gets skimmed.
|
|
1147
|
+
const unreadable = report.findings.filter((finding) => finding.state === "UNREADABLE").length;
|
|
1148
|
+
const total = report.findings.length;
|
|
1149
|
+
const lines = [
|
|
1150
|
+
`VERIFICATION WAVE — the undelete of wave ${wave}, checked against that wave's own pre-image (${report.checkedAt})`,
|
|
1151
|
+
unreadable === 0
|
|
1152
|
+
? `all ${total} carded component(s) were read and compared.`
|
|
1153
|
+
: unreadable === total
|
|
1154
|
+
? `NOTHING WAS COMPARED — the org could not be read for any of the ${total} carded component(s), under either name. Nothing below is a claim about whether the undelete happened.`
|
|
1155
|
+
: `${total - unreadable} of ${total} carded component(s) were read and compared; ${unreadable} could not be read and carry no claim either way.`,
|
|
1156
|
+
""
|
|
1157
|
+
];
|
|
1158
|
+
for (const finding of report.findings) {
|
|
1159
|
+
lines.push(`${finding.state} ${finding.key}`);
|
|
1160
|
+
for (const sentence of wrap(finding.detail, 100))
|
|
1161
|
+
lines.push(` ${sentence}`);
|
|
1162
|
+
for (const change of finding.differences) {
|
|
1163
|
+
lines.push(` - ${change.path}: pre-image ${change.before} → now ${change.after}${change.orderOnly ? " (order only)" : ""}`);
|
|
1164
|
+
}
|
|
1165
|
+
lines.push("");
|
|
1166
|
+
}
|
|
1167
|
+
lines.push("This compares METADATA SHAPE against the pre-image. It does not and cannot prove the records' values came", "back — the field was unreadable while deleted, so nothing recorded what they were. Open a record and look.");
|
|
1168
|
+
return lines.join("\n");
|
|
1169
|
+
}
|
|
1170
|
+
// ---------------------------------------------------------------------------------------------
|
|
1171
|
+
// rendering
|
|
1172
|
+
// ---------------------------------------------------------------------------------------------
|
|
1173
|
+
const HEADINGS = {
|
|
1174
|
+
EXECUTE: "WILL EXECUTE",
|
|
1175
|
+
HELD_FOR_ACK: "HELD — needs a named ack",
|
|
1176
|
+
// "Settled in Setup" rather than "undelete in Setup": the same disposition also carries the field
|
|
1177
|
+
// whose window is out on a clock nothing checked, and there the operator's job is to find out
|
|
1178
|
+
// WHICH it is. Promising an undelete to someone who may find nothing to undelete is the same
|
|
1179
|
+
// overclaim as the header this module's last defect produced, one line further down.
|
|
1180
|
+
OPERATOR_ACTION: "OPERATOR ACTION — settled in Setup, never a deploy",
|
|
1181
|
+
REPORTED: "REPORTED — nothing for this tool to execute"
|
|
1182
|
+
};
|
|
1183
|
+
/** `n component(s)`, or `it`/`them` where the count is already on the line. */
|
|
1184
|
+
const plural = (n, singular, many) => (n === 1 ? singular : many);
|
|
1185
|
+
/**
|
|
1186
|
+
* The R16 preflight's summary — what it COMPARED, never what it was asked to compare.
|
|
1187
|
+
*
|
|
1188
|
+
* The count-zero case gets its own branch and its own verb. A read that answered for nothing did
|
|
1189
|
+
* not preflight anything, and it borrows the not-read branch's sentence verbatim, because that is
|
|
1190
|
+
* exactly what it is: no divergence below because none was looked for.
|
|
1191
|
+
*/
|
|
1192
|
+
function liveStateLines(live) {
|
|
1193
|
+
const noneLookedFor = "No divergence is reported below because none was looked for; that is not the same as live state agreeing with what this wave recorded.";
|
|
1194
|
+
const notRead = (error) => [
|
|
1195
|
+
`live state: NOT READ (${error ?? "unavailable"}) — the R16 preflight DID NOT RUN. ${noneLookedFor}`
|
|
1196
|
+
];
|
|
1197
|
+
if (!live)
|
|
1198
|
+
return notRead(undefined);
|
|
1199
|
+
const unreadable = live.scope - live.count;
|
|
1200
|
+
// `unknown` can be shorter than the count — a component the projection never mentioned is
|
|
1201
|
+
// unreadable with no reason to give — so the count comes from the scope and the list is detail.
|
|
1202
|
+
const detail = live.unknown.length > 0
|
|
1203
|
+
? ` the org could not be read for ${unreadable}: ${live.unknown.join("; ")}. Those carry NO live claim either way.`
|
|
1204
|
+
: ` ${unreadable} component(s) in scope came back with no answer at all. Those carry NO live claim either way.`;
|
|
1205
|
+
switch (live.status) {
|
|
1206
|
+
case "NOT_ATTEMPTED":
|
|
1207
|
+
case "FAILED":
|
|
1208
|
+
return notRead(live.error);
|
|
1209
|
+
case "READ":
|
|
1210
|
+
return [
|
|
1211
|
+
`live state: READ — all ${live.count} component(s) in scope were read from the org, and the R16 preflight compared every one of them.`
|
|
1212
|
+
];
|
|
1213
|
+
case "PARTIAL":
|
|
1214
|
+
return [
|
|
1215
|
+
`live state: PARTIAL — ${live.count} of ${live.scope} component(s) in scope were read and compared; ${unreadable} could not be read, so the R16 preflight did not run against ${plural(unreadable, "it", "them")}.`,
|
|
1216
|
+
detail
|
|
1217
|
+
];
|
|
1218
|
+
case "NONE_READABLE":
|
|
1219
|
+
return [
|
|
1220
|
+
`live state: NOT COMPARED — the read was issued and answered for 0 of ${live.scope} component(s), so the R16 preflight compared NOTHING. ${noneLookedFor}`,
|
|
1221
|
+
detail
|
|
1222
|
+
];
|
|
1223
|
+
case "NOTHING_IN_SCOPE":
|
|
1224
|
+
return [
|
|
1225
|
+
"live state: NOTHING IN SCOPE — no component was selected for the R16 preflight to read, so it compared nothing."
|
|
1226
|
+
];
|
|
1227
|
+
}
|
|
1228
|
+
// A status this renderer has not been taught falls to the CONSERVATIVE line. Every switch above is
|
|
1229
|
+
// exhaustive today, so this is unreachable — and it is here because the alternative default (the
|
|
1230
|
+
// last case listed) would render an unknown status as "nothing to read", the single most
|
|
1231
|
+
// reassuring sentence in the set, in a module whose thesis is that a summary may not overclaim.
|
|
1232
|
+
return notRead(live.error);
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* The deleted-field probe's summary — what ANSWERED, and what an unanswered probe costs.
|
|
1236
|
+
*
|
|
1237
|
+
* Every branch that is not MEASURED names the consequence in the same sentence as the status, in
|
|
1238
|
+
* the words `classifyDeletedField` uses per component: presence UNVERIFIED, not absent, and the
|
|
1239
|
+
* window is the clock's answer alone.
|
|
1240
|
+
*/
|
|
1241
|
+
function probeLines(probe) {
|
|
1242
|
+
const notRun = [
|
|
1243
|
+
"deleted-field probe: NOT RUN — presence in Salesforce's Deleted Fields list is UNVERIFIED, which is not the same as absent."
|
|
1244
|
+
];
|
|
1245
|
+
if (!probe)
|
|
1246
|
+
return notRun;
|
|
1247
|
+
// EACH COMPONENT CARRIES ITS OWN REASON. `failures` is keyed by object and `unverified` by
|
|
1248
|
+
// component; printing the object errors as one parenthetical and the component keys as another
|
|
1249
|
+
// would invite the reader to pair them up, and they do not always pair.
|
|
1250
|
+
const reasons = probe.unverified.map((entry) => ` ${entry.key} — ${entry.reason}`);
|
|
1251
|
+
switch (probe.status) {
|
|
1252
|
+
case "NOT_ATTEMPTED":
|
|
1253
|
+
return notRun;
|
|
1254
|
+
case "MEASURED":
|
|
1255
|
+
return [
|
|
1256
|
+
`deleted-field probe: MEASURED for ${probe.verified.length} component(s) — presence in Salesforce's Deleted Fields list was read from the org, not derived from the clock.`
|
|
1257
|
+
];
|
|
1258
|
+
case "PARTIAL":
|
|
1259
|
+
return [
|
|
1260
|
+
`deleted-field probe: PARTIAL — measured for ${probe.verified.length} component(s), and ${probe.unverified.length} went unanswered. ` +
|
|
1261
|
+
`Presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent — for ${plural(probe.unverified.length, "that component, whose window is", "those components, whose windows are")} the clock's answer alone:`,
|
|
1262
|
+
...reasons
|
|
1263
|
+
];
|
|
1264
|
+
case "FAILED":
|
|
1265
|
+
return [
|
|
1266
|
+
`deleted-field probe: ATTEMPTED, FAILED — nothing answered. ` +
|
|
1267
|
+
`Presence in Salesforce's Deleted Fields list is UNVERIFIED — not absent — for all ${probe.unverified.length} component(s) it was asked about, and every window below is the clock's answer alone:`,
|
|
1268
|
+
...reasons
|
|
1269
|
+
];
|
|
1270
|
+
case "NOT_NEEDED":
|
|
1271
|
+
return [
|
|
1272
|
+
"deleted-field probe: NOT NEEDED — no component in this plan is a deleted field, so there is no Deleted Fields list to read and none was."
|
|
1273
|
+
];
|
|
1274
|
+
}
|
|
1275
|
+
return notRun; // Unknown status → the conservative line. See `liveStateLines`.
|
|
1276
|
+
}
|
|
1277
|
+
function renderPlan(input, result, gated) {
|
|
1278
|
+
const executed = result.applied !== undefined;
|
|
1279
|
+
const lines = [
|
|
1280
|
+
`${executed ? "ROLLBACK EXECUTED" : "ROLLBACK PLAN"} — wave ${input.wave} of session ${input.sessionId} (org ${input.orgId}), classified ${result.asOf}`,
|
|
1281
|
+
`${gated.length} component(s) classified${input.components ? " from the selection given" : " — the whole wave"}.`
|
|
1282
|
+
];
|
|
1283
|
+
// R16 IS OPT-IN AND SILENT WHEN OMITTED, so its absence is stated rather than left to be inferred
|
|
1284
|
+
// from a card with no divergence on it. Absence of a divergence is not absence of divergence.
|
|
1285
|
+
//
|
|
1286
|
+
// AND NEITHER OF THESE TWO LINES MAY RESOLVE OFF "WAS ATTEMPTED". They are the first thing an
|
|
1287
|
+
// operator reads and everything below them is per-component; a summary that says MEASURED over
|
|
1288
|
+
// evidence that says UNVERIFIED is not a wording problem, it is the header contradicting the
|
|
1289
|
+
// finding. See `LiveStateReport` for the live case that caught this. Both statuses distinguish
|
|
1290
|
+
// not-attempted from attempted-and-failed from partial, and every branch states the consequence
|
|
1291
|
+
// in the same breath as the status.
|
|
1292
|
+
lines.push(...liveStateLines(result.liveState), ...probeLines(result.plan?.probe));
|
|
1293
|
+
if (input.writeBlocked)
|
|
1294
|
+
lines.push(`writes: ${input.writeBlocked}`);
|
|
1295
|
+
if (result.reclassified?.length) {
|
|
1296
|
+
lines.push("", "RECLASSIFIED AT EXECUTION TIME — the plan you read is no longer the plan:");
|
|
1297
|
+
for (const entry of result.reclassified) {
|
|
1298
|
+
lines.push(` ${entry.key}: ${entry.from} → ${entry.to}`);
|
|
1299
|
+
for (const sentence of wrap(entry.reason, 100))
|
|
1300
|
+
lines.push(` ${sentence}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
for (const disposition of ["EXECUTE", "HELD_FOR_ACK", "OPERATOR_ACTION", "REPORTED"]) {
|
|
1304
|
+
const entries = gated.filter((entry) => entry.disposition === disposition);
|
|
1305
|
+
if (entries.length === 0)
|
|
1306
|
+
continue;
|
|
1307
|
+
lines.push("", `${HEADINGS[disposition]} (${entries.length})`);
|
|
1308
|
+
for (const entry of entries) {
|
|
1309
|
+
// An OPERATOR_ACTION entry's detail is its CARD, printed in full below — repeating the action
|
|
1310
|
+
// line here would say less in more space. Everything that would be lost by skipping it has to
|
|
1311
|
+
// reach the card instead, which is why `renderUndeleteCard` carries the R16 divergence: this
|
|
1312
|
+
// `continue` used to drop that finding on the floor for the one class that cannot afford it.
|
|
1313
|
+
if (disposition === "OPERATOR_ACTION")
|
|
1314
|
+
continue;
|
|
1315
|
+
lines.push(` ${entry.component.key} — ${entry.component.class}`);
|
|
1316
|
+
for (const sentence of wrap(entry.component.action, 100))
|
|
1317
|
+
lines.push(` ${sentence}`);
|
|
1318
|
+
if (entry.component.limitation) {
|
|
1319
|
+
for (const sentence of wrap(`REFUSED: ${entry.component.limitation}`, 100))
|
|
1320
|
+
lines.push(` ${sentence}`);
|
|
1321
|
+
}
|
|
1322
|
+
// THE REASONS COME BEFORE THE RELEASE TOKEN, and the token says how many it covers. One key
|
|
1323
|
+
// releases every reason on the component (see `GatedComponent.ackKey`), so an operator who
|
|
1324
|
+
// read one reason and typed the key would be answering for all of them without being told.
|
|
1325
|
+
for (const reason of entry.ackReasons) {
|
|
1326
|
+
for (const sentence of wrap(`• ${reason}`, 100))
|
|
1327
|
+
lines.push(` ${sentence}`);
|
|
1328
|
+
}
|
|
1329
|
+
if (entry.ackKey && disposition === "HELD_FOR_ACK") {
|
|
1330
|
+
lines.push(entry.ackReasons.length > 1
|
|
1331
|
+
? ` release it with: ackComponents: ["${entry.ackKey}"] <- this ONE key accepts ALL ${entry.ackReasons.length} reasons above`
|
|
1332
|
+
: ` release it with: ackComponents: ["${entry.ackKey}"]`);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
for (const card of result.cards ?? [])
|
|
1337
|
+
lines.push("", card.text);
|
|
1338
|
+
if (result.schedule) {
|
|
1339
|
+
lines.push("", `A verification wave is scheduled in ${path.join(input.rollbackBundleDir, UNDELETE_SCHEDULE_FILE)}.`);
|
|
1340
|
+
}
|
|
1341
|
+
if (result.applied) {
|
|
1342
|
+
const applied = result.applied;
|
|
1343
|
+
lines.push("", `deploy verdict: ${applied.verdict}`, `bundle: ${applied.bundleDir}`, ...(applied.mirror
|
|
1344
|
+
? [
|
|
1345
|
+
applied.mirror.error
|
|
1346
|
+
? `history: NOT RECORDED (${applied.mirror.error})`
|
|
1347
|
+
: applied.mirror.committed
|
|
1348
|
+
? `history: recorded as ${applied.mirror.sha?.slice(0, 12)}, marked a revert of ${input.sessionId}/${input.wave} (the sha is a display alias, never store it)`
|
|
1349
|
+
: "history: nothing to record — this rollback left the org exactly as it was"
|
|
1350
|
+
]
|
|
1351
|
+
: []), ...(applied.operatorSummary ? [`operator summary: ${applied.operatorSummary}`] : []), ...applied.notes);
|
|
1352
|
+
}
|
|
1353
|
+
else if (input.mode === "plan") {
|
|
1354
|
+
const held = gated.filter((entry) => entry.disposition === "HELD_FOR_ACK").map((entry) => entry.component.key);
|
|
1355
|
+
lines.push("", "NOTHING HAS BEEN CHANGED. This call classified and carded only.", ` execute it with: direct_rollback {sessionId: "${input.sessionId}", wave: "${input.wave}", mode: "execute"${held.length ? `, ackComponents: [${held.map((key) => `"${key}"`).join(", ")}]` : ""}}`);
|
|
1356
|
+
}
|
|
1357
|
+
for (const note of [...result.notes, ...(result.plan?.notes ?? [])])
|
|
1358
|
+
lines.push(`note: ${note}`);
|
|
1359
|
+
return lines.join("\n");
|
|
1360
|
+
}
|