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,2516 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import { flushParcels, queueWaveParcel } from "../core/parcels.js";
|
|
5
|
+
import { runProcess, sfCliEnv } from "../core/spawn.js";
|
|
6
|
+
import { orgCallPhase, resetOrgCallMeter } from "../core/orgCallMeter.js";
|
|
7
|
+
import { ScopeTooLargeError, runProjection } from "../projection/project.js";
|
|
8
|
+
import { PRECEDENT_TYPE_NAMES } from "../projection/precedent.js";
|
|
9
|
+
import { canonJson, diffProjections, diffMetadata } from "../projection/diff.js";
|
|
10
|
+
import { incompleteFacetsOf, normalizeCustomFieldMetadata, valueSetDefaultValue } from "../projection/bridge.js";
|
|
11
|
+
import { parseMetadataXml, runLint } from "./lint.js";
|
|
12
|
+
import { applyRefVerification, candidateObjects, escalatePendingDependencies, referentialTokens, verifyRefsAgainstOrg } from "./refVerify.js";
|
|
13
|
+
import { collectSessionProjection, viewKnownTokens, viewProvenValues } from "./sessionProjection.js";
|
|
14
|
+
import { findRemovals, isSourceFormatArtifact, renderRemovals } from "./removals.js";
|
|
15
|
+
import { findUnstatedChanges, renderUnstatedChanges } from "./unstatedChanges.js";
|
|
16
|
+
import { PLATFORM_DEFAULT_WHEN_OMITTED, canonicalizeCurrencyish, coerceLeaf, isEmptyish, isPlainObj } from "./comparatorNoise.js";
|
|
17
|
+
import { preImageTargets } from "./preImageTargets.js";
|
|
18
|
+
import { registryDeployableTypes, resolveFromRegistry } from "./registry.js";
|
|
19
|
+
import { isStateChanging, overlayAndCommit, readWaveFromBundle } from "./orgRepo.js";
|
|
20
|
+
import { destructivePackageXml, emptyPackageXml, parseDestructiveResult } from "./destructive.js";
|
|
21
|
+
/** Plain-language rendering of a result for operator surfaces — see harness/reference/operator-voice.md. */
|
|
22
|
+
export function operatorSummaryFor(r) {
|
|
23
|
+
return `${verdictSentence(r)}${driftSentence(r.mirror?.drifted ?? [])}${notComparableSentence(r.mirror?.incomparable ?? [])}`;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The gap in the record, in the operator's language.
|
|
27
|
+
*
|
|
28
|
+
* Says "could not check", never "unchanged". The temptation is to leave this out entirely — it is
|
|
29
|
+
* our own tooling's limitation and it reads like an excuse — but a check that did not happen is
|
|
30
|
+
* exactly the thing an operator must not be allowed to mistake for a check that passed.
|
|
31
|
+
*/
|
|
32
|
+
function notComparableSentence(incomparable) {
|
|
33
|
+
if (incomparable.length === 0)
|
|
34
|
+
return "";
|
|
35
|
+
const named = incomparable.slice(0, 3).map((e) => e.key).join(", ");
|
|
36
|
+
const rest = incomparable.length > 3 ? ` and ${incomparable.length - 3} more` : "";
|
|
37
|
+
return (` Also: for ${incomparable.length} component(s) (${named}${rest}) this machine could NOT check whether ` +
|
|
38
|
+
`anything had changed since IN2 last touched them — its record of them was taken by an older version of ` +
|
|
39
|
+
`the software that reads the org, so the two are not comparable. That is not a clean result and not a ` +
|
|
40
|
+
`warning; it is a check that could not run. It fixes itself: this wave re-records them the current way.`);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* R15's card text. A WARNING appended to whatever the verdict says, never a verdict of its own —
|
|
44
|
+
* the wave succeeded, and someone having edited the component beforehand does not change that.
|
|
45
|
+
*
|
|
46
|
+
* The claim is deliberately narrow. Until parcels sync (R7/R24), this machine's mirror holds only
|
|
47
|
+
* the waves THIS install delivered, so a component another consultant changed last week looks
|
|
48
|
+
* identical to one a client admin changed in Setup. "Outside this machine's history" is what the
|
|
49
|
+
* evidence actually supports; "changed in Setup" would be a guess wearing a fact's clothes.
|
|
50
|
+
*/
|
|
51
|
+
function driftSentence(drifted) {
|
|
52
|
+
if (drifted.length === 0)
|
|
53
|
+
return "";
|
|
54
|
+
const named = drifted.slice(0, 3).join(", ");
|
|
55
|
+
const rest = drifted.length > 3 ? ` and ${drifted.length - 3} more` : "";
|
|
56
|
+
return (` Heads-up: ${drifted.length} component(s) had already been changed outside this machine's history ` +
|
|
57
|
+
`since IN2 last touched them (${named}${rest}) — a client admin in Setup, or another machine whose ` +
|
|
58
|
+
`history has not reached here. The wave still went in; check that it did not overwrite someone else's edit.`);
|
|
59
|
+
}
|
|
60
|
+
function verdictSentence(r) {
|
|
61
|
+
switch (r.verdict) {
|
|
62
|
+
case "DIFF_VERIFIED":
|
|
63
|
+
return "Deployed and verified — every setting matches the specification.";
|
|
64
|
+
case "VALIDATED": {
|
|
65
|
+
// Files AND deletes are both submitted to the org for a real practice run. Object facet
|
|
66
|
+
// updates are the one path with no validation step, so a dry run over them checked NOTHING —
|
|
67
|
+
// and this sentence is what an operator reads instead of the result. Claiming "Salesforce
|
|
68
|
+
// accepted everything" over work that was never submitted anywhere is the same class of
|
|
69
|
+
// overstatement `facetVerifiedStatus` exists to prevent one stage later.
|
|
70
|
+
// Read from `dryRun.checked` rather than inferred: a delete-only wave still produces a
|
|
71
|
+
// `dryRun` section, and inferring files from its presence would tell the operator that files
|
|
72
|
+
// were deployed in a wave that carried none.
|
|
73
|
+
const ran = r.dryRun?.checked ?? (r.dryRun ? ["files"] : []);
|
|
74
|
+
const checked = [
|
|
75
|
+
...(ran.includes("files") ? ["the deployed files"] : []),
|
|
76
|
+
...(ran.includes("deletes") && r.deletes?.length ? [`${r.deletes.length} deletion(s)`] : [])
|
|
77
|
+
];
|
|
78
|
+
if (!r.facetUpdates?.length) {
|
|
79
|
+
return "Salesforce accepted everything in a practice run — nothing has been changed in the org yet.";
|
|
80
|
+
}
|
|
81
|
+
const accepted = checked.length
|
|
82
|
+
? `Salesforce accepted ${checked.join(" and ")} in a practice run, but `
|
|
83
|
+
: "Nothing here was checked at all: ";
|
|
84
|
+
return (`${accepted}${r.facetUpdates.length} object setting change(s) cannot be practice-run — Salesforce offers ` +
|
|
85
|
+
`no validation step for that path, so this result says nothing about whether they would work. Re-run ` +
|
|
86
|
+
`without the practice-run option to apply them and get real verification. Either way, nothing has been ` +
|
|
87
|
+
`changed in the org yet.`);
|
|
88
|
+
}
|
|
89
|
+
case "LINT_BLOCKED":
|
|
90
|
+
return "Stopped by pre-flight checks before touching the org — nothing was changed; fix the named findings and re-run.";
|
|
91
|
+
case "REJECTED_BY_ORG":
|
|
92
|
+
return "Salesforce declined this step, so nothing from it was applied — the org's exact reasons are in the result.";
|
|
93
|
+
case "AMBIGUOUS_DELIVERY":
|
|
94
|
+
return "The change was sent but confirmation timed out — it may have landed. Do not re-send; re-read the org first.";
|
|
95
|
+
case "DELIVERED_WITH_DRIFT": {
|
|
96
|
+
const mismatches = (r.intent ?? []).reduce((n, i) => n + i.mismatches.length, 0);
|
|
97
|
+
const parts = [];
|
|
98
|
+
if (mismatches > 0)
|
|
99
|
+
parts.push(`${mismatches} setting(s) read back differently than authored`);
|
|
100
|
+
if (r.sideChanges?.length)
|
|
101
|
+
parts.push(`${r.sideChanges.length} other component(s) also moved`);
|
|
102
|
+
if (r.removals?.length)
|
|
103
|
+
parts.push(`${r.removals.length} component(s) lost content in a full replace`);
|
|
104
|
+
if (r.unstatedChanges?.length) {
|
|
105
|
+
parts.push(`${r.unstatedChanges.length} component(s) had a setting change that nobody asked for`);
|
|
106
|
+
}
|
|
107
|
+
if (r.scanIncomplete)
|
|
108
|
+
parts.push("the org-wide check for concurrent changes could not finish, so nothing here rules one out");
|
|
109
|
+
return `Deployed; a few readings need checking before calling it done (${parts.join("; ") || "see the result"}). This is often Salesforce normalizing values — classify each against the evidence, then report in plain terms.`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// Source-format layout per metadata type. Types outside this table refuse loudly —
|
|
114
|
+
// an honest, extensible ceiling (the direct tier's coverage grows by adding rows,
|
|
115
|
+
// not by building five toolchain seams).
|
|
116
|
+
const SOURCE_LAYOUT = {
|
|
117
|
+
CustomObject: (f) => `objects/${f}/${f}.object-meta.xml`,
|
|
118
|
+
CustomField: (f) => {
|
|
119
|
+
const [obj, field] = f.split(".");
|
|
120
|
+
return `objects/${obj}/fields/${field}.field-meta.xml`;
|
|
121
|
+
},
|
|
122
|
+
ValidationRule: (f) => {
|
|
123
|
+
const [obj, rule] = f.split(".");
|
|
124
|
+
return `objects/${obj}/validationRules/${rule}.validationRule-meta.xml`;
|
|
125
|
+
},
|
|
126
|
+
ListView: (f) => {
|
|
127
|
+
const [obj, view] = f.split(".");
|
|
128
|
+
return `objects/${obj}/listViews/${view}.listView-meta.xml`;
|
|
129
|
+
},
|
|
130
|
+
CompactLayout: (f) => {
|
|
131
|
+
const [obj, cl] = f.split(".");
|
|
132
|
+
return `objects/${obj}/compactLayouts/${cl}.compactLayout-meta.xml`;
|
|
133
|
+
},
|
|
134
|
+
RecordType: (f) => {
|
|
135
|
+
const [obj, rt] = f.split(".");
|
|
136
|
+
return `objects/${obj}/recordTypes/${rt}.recordType-meta.xml`;
|
|
137
|
+
},
|
|
138
|
+
Layout: (f) => `layouts/${f}.layout-meta.xml`,
|
|
139
|
+
Flow: (f) => `flows/${f}.flow-meta.xml`,
|
|
140
|
+
FlexiPage: (f) => `flexipages/${f}.flexipage-meta.xml`,
|
|
141
|
+
CustomTab: (f) => `tabs/${f}.tab-meta.xml`,
|
|
142
|
+
PermissionSet: (f) => `permissionsets/${f}.permissionset-meta.xml`,
|
|
143
|
+
CustomApplication: (f) => `applications/${f}.app-meta.xml`,
|
|
144
|
+
Queue: (f) => `queues/${f}.queue-meta.xml`,
|
|
145
|
+
Group: (f) => `groups/${f}.group-meta.xml`,
|
|
146
|
+
// SharingRules deploys as the per-object CONTAINER (fullName = object API name).
|
|
147
|
+
SharingRules: (f) => `sharingRules/${f}.sharingRules-meta.xml`,
|
|
148
|
+
ApprovalProcess: (f) => `approvalProcesses/${f}.approvalProcess-meta.xml`,
|
|
149
|
+
// Profiles deploy as MERGES — a fragment carrying only the changed sections is
|
|
150
|
+
// legal and is the sanctioned way to grant e.g. applicationVisibilities.
|
|
151
|
+
Profile: (f) => `profiles/${f}.profile-meta.xml`,
|
|
152
|
+
// Real-corpus wave (2026-07-22 classification): PSG alone uncapped 27 of 162 reqs.
|
|
153
|
+
PermissionSetGroup: (f) => `permissionsetgroups/${f}.permissionsetgroup-meta.xml`,
|
|
154
|
+
Report: (f) => `reports/${f}.report-meta.xml`, // fullName carries Folder/Name
|
|
155
|
+
ReportFolder: (f) => `reports/${f}.reportFolder-meta.xml`,
|
|
156
|
+
Dashboard: (f) => `dashboards/${f}.dashboard-meta.xml`,
|
|
157
|
+
DashboardFolder: (f) => `dashboards/${f}.dashboardFolder-meta.xml`,
|
|
158
|
+
DuplicateRule: (f) => `duplicateRules/${f}.duplicateRule-meta.xml`,
|
|
159
|
+
MatchingRules: (f) => `matchingRules/${f}.matchingRule-meta.xml`, // per-object container
|
|
160
|
+
AssignmentRules: (f) => `assignmentRules/${f}.assignmentRules-meta.xml`, // per-object container
|
|
161
|
+
CustomMetadata: (f) => `customMetadata/${f}.md-meta.xml`
|
|
162
|
+
};
|
|
163
|
+
// The explicit object-child layouts (fields/rules/… under objects/) plus every
|
|
164
|
+
// deployable type the SDR registry knows (single-file XML + content-file) — the
|
|
165
|
+
// derived open-world surface (bundles excluded until the files[] contract).
|
|
166
|
+
export function supportedDirectTypes() {
|
|
167
|
+
return [...new Set([...Object.keys(SOURCE_LAYOUT), ...registryDeployableTypes()])].sort();
|
|
168
|
+
}
|
|
169
|
+
// Explicit object-child map first (stable, tested), then registry-derived. Content-file
|
|
170
|
+
// types require a `content` value; refused with a clear message when it's missing.
|
|
171
|
+
export function resolveSourcePath(edit) {
|
|
172
|
+
const explicit = SOURCE_LAYOUT[edit.typeName];
|
|
173
|
+
if (explicit)
|
|
174
|
+
return { files: [{ relPath: explicit(edit.fullName), from: "xml" }] };
|
|
175
|
+
const r = resolveFromRegistry(edit.typeName, edit.fullName);
|
|
176
|
+
if (!r.ok)
|
|
177
|
+
return { error: r.message };
|
|
178
|
+
if (r.kind === "content") {
|
|
179
|
+
if (edit.content === undefined) {
|
|
180
|
+
return { error: `${edit.typeName} is a content-file type — provide a \`content\` value (the ${r.contentPath.split(".").pop()} body).` };
|
|
181
|
+
}
|
|
182
|
+
return { files: [{ relPath: r.metaPath, from: "xml" }, { relPath: r.contentPath, from: "content" }] };
|
|
183
|
+
}
|
|
184
|
+
if (r.kind === "container-child") {
|
|
185
|
+
return { container: { containerPath: r.containerPath, parentRoot: r.parentRoot, childElementName: r.childElementName } };
|
|
186
|
+
}
|
|
187
|
+
return { files: [{ relPath: r.relPath, from: "xml" }] };
|
|
188
|
+
}
|
|
189
|
+
// Re-root a decomposed child's XML into its parent container element: strip the declaration,
|
|
190
|
+
// swap `<ChildType …>…</ChildType>` for `<childElementName>…</childElementName>`.
|
|
191
|
+
function wrapChildElement(childXml, childType, elementName) {
|
|
192
|
+
let inner = childXml.trim().replace(/^<\?xml[^>]*\?>\s*/, "");
|
|
193
|
+
inner = inner.replace(new RegExp(`^<${childType}(\\s[^>]*)?>`), `<${elementName}>`);
|
|
194
|
+
inner = inner.replace(new RegExp(`</${childType}>\\s*$`), `</${elementName}>`);
|
|
195
|
+
return inner;
|
|
196
|
+
}
|
|
197
|
+
const XML_NS = 'xmlns="http://soap.sforce.com/2006/04/metadata"';
|
|
198
|
+
// Ensure the root element declares every namespace the document actually USES.
|
|
199
|
+
// The default sforce namespace is added when absent; and — live-caught on CF65
|
|
200
|
+
// CustomMetadata seed records — the schema-instance prefixes (`xsi`, `xsd`) must be
|
|
201
|
+
// BOUND whenever the XML uses them (CustomMetadata `<value xsi:type="xsd:string">`,
|
|
202
|
+
// or `xsi:nil`). An unbound prefix makes SF's mdapi reject the whole file with a
|
|
203
|
+
// 0-component UNKNOWN_EXCEPTION rather than a real component error. Exported for tests.
|
|
204
|
+
export function ensureDeployableXml(xml) {
|
|
205
|
+
let out = xml.trim();
|
|
206
|
+
if (!out.startsWith("<?xml"))
|
|
207
|
+
out = `<?xml version="1.0" encoding="UTF-8"?>\n${out}`;
|
|
208
|
+
const needed = [];
|
|
209
|
+
if (!/\sxmlns\s*=/.test(out))
|
|
210
|
+
needed.push(XML_NS);
|
|
211
|
+
if (/\bxsi:/.test(out) && !/xmlns:xsi\s*=/.test(out)) {
|
|
212
|
+
needed.push('xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"');
|
|
213
|
+
}
|
|
214
|
+
if (/\bxsd:/.test(out) && !/xmlns:xsd\s*=/.test(out)) {
|
|
215
|
+
needed.push('xmlns:xsd="http://www.w3.org/2001/XMLSchema"');
|
|
216
|
+
}
|
|
217
|
+
if (needed.length > 0) {
|
|
218
|
+
// Insert on the first real element (skips the <?xml …?> declaration).
|
|
219
|
+
out = out.replace(/<([A-Za-z][A-Za-z0-9]*)(\s|>)/, (_m, tag, tail) => `<${tag} ${needed.join(" ")}${tail}`);
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
// THE READ-BACK BACKOFF IS GONE, and the corpus is why.
|
|
224
|
+
//
|
|
225
|
+
// It was sized TWICE against evidence that turned out to be confounded: "component reads back absent
|
|
226
|
+
// after a successful deploy" was never Salesforce, it was the reader serving the pre-projection's
|
|
227
|
+
// not-found rows to the post-projection. Every apparent settle time — 17s, 22s, 38s — was the
|
|
228
|
+
// operator re-running project_org against a FRESH reader, so those numbers measured our own cache.
|
|
229
|
+
// After `invalidate()` landed, the sleep ladder stayed on the hypothesis that a genuine propagation
|
|
230
|
+
// delay might still exist, and the comment on `readbackWaitMs` said outright what would retire it:
|
|
231
|
+
// "if `readbackWaitMs` is 0 across a corpus, the loop can go."
|
|
232
|
+
//
|
|
233
|
+
// It is 0 across the corpus. 28 of 28 waves that recorded it waited exactly zero milliseconds —
|
|
234
|
+
// `settlingIsPlausible` short-circuits every one of them. The 17 older bundles where the ladder DID
|
|
235
|
+
// fire are the other half of the answer: not one of them settled. They paid 9.5s (four of them 47s,
|
|
236
|
+
// on the schedule bad evidence bought) and then reported drift anyway. So the loop's measured record
|
|
237
|
+
// is zero benefit and a per-wave landmine of a sleep plus a full extra post-projection.
|
|
238
|
+
//
|
|
239
|
+
// `settlingIsPlausible` STAYS — it is the thing that makes the wait zero, and it is now what decides
|
|
240
|
+
// the note instead of what decides the sleep. A facet that genuinely does need settling reports
|
|
241
|
+
// DELIVERED_WITH_DRIFT on the first read, which the loop already documents as noisy-but-safe, and
|
|
242
|
+
// the operator re-reads. `readbackWaitMs: 0` is still emitted, so the ledger's history stays
|
|
243
|
+
// continuous rather than developing a hole where this build starts.
|
|
244
|
+
/**
|
|
245
|
+
* Facets Salesforce settles ASYNCHRONOUSLY, so re-reading them later could legitimately change the
|
|
246
|
+
* answer. Matched on the mismatch's leaf name.
|
|
247
|
+
*
|
|
248
|
+
* This list used to be the reason the backoff survived. With the backoff gone (see above) it is no
|
|
249
|
+
* longer a wait/no-wait switch — nothing waits — but it is still the honest catalogue of which
|
|
250
|
+
* mismatches are the org's FINAL answer and which are a read taken mid-recalculation, and that is
|
|
251
|
+
* what the note tells the operator. The three cases that ever settled were:
|
|
252
|
+
* w1 a component read back absent — that one was our own reader cache, now fixed
|
|
253
|
+
* w4b compactLayoutAssignment → SYSTEM — present, facet still the old value
|
|
254
|
+
* w7 sharingOwnerRules → [] — present, and recalculating; Salesforce documents
|
|
255
|
+
* sharing recalculation as asynchronous
|
|
256
|
+
* Only the last two are real org behaviour, and both are narrow and nameable.
|
|
257
|
+
*
|
|
258
|
+
* The sharing-rules entries were the whole of the sharing story until the EV-31…40 run, which asked
|
|
259
|
+
* for a `sharingModel` change and got a false "NOT APPLIED — the org accepted the request but still
|
|
260
|
+
* reads …" on the first read; both APIs showed it applied moments later. Same asynchronous
|
|
261
|
+
* recalculation, one level up — the rules were catalogued and the MODEL that drives them was not,
|
|
262
|
+
* which is the narrowest possible version of the gap this list is for. `internalSharingModel` and
|
|
263
|
+
* `externalSharingModel` join it: they are the same facet under the spellings an org with external
|
|
264
|
+
* sharing enabled reads back.
|
|
265
|
+
*
|
|
266
|
+
* Everything NOT on this list concludes on the first read. A field whose length came back 255
|
|
267
|
+
* instead of 40 is the org's answer, not a work in progress, and waiting 9.5s to ask again was pure
|
|
268
|
+
* cost — paid on every drifted wave, to re-learn what the first read already said.
|
|
269
|
+
*
|
|
270
|
+
* Membership now changes the WORDING and nothing else, which makes the whole list cheap to be wrong
|
|
271
|
+
* about in either direction: every mismatch reports DELIVERED_WITH_DRIFT immediately, and the note
|
|
272
|
+
* says whether re-reading is worth the operator's time.
|
|
273
|
+
*/
|
|
274
|
+
const ASYNC_SETTLING_LEAVES = new Set([
|
|
275
|
+
"compactlayoutassignment",
|
|
276
|
+
"sharingmodel",
|
|
277
|
+
"internalsharingmodel",
|
|
278
|
+
"externalsharingmodel",
|
|
279
|
+
"sharingownerrules",
|
|
280
|
+
"sharingcriteriarules",
|
|
281
|
+
"sharingguestrules",
|
|
282
|
+
"sharingterritoryrules"
|
|
283
|
+
]);
|
|
284
|
+
/** Is this mismatch one that re-reading could plausibly resolve? Exported for tests: each entry in
|
|
285
|
+
* the set above is a claim about platform behaviour that should be pinned by a case. */
|
|
286
|
+
export function settlingIsPlausible(entries) {
|
|
287
|
+
return entries.some((entry) =>
|
|
288
|
+
// A component the org has not shown us yet may simply not be readable back yet. Kept because a
|
|
289
|
+
// create that reads back absent is the ambiguous-delivery case — with the cache bug fixed it
|
|
290
|
+
// should be rare, and when it does happen "re-read it" is the right advice.
|
|
291
|
+
entry.absent ||
|
|
292
|
+
entry.paths.some((path) => ASYNC_SETTLING_LEAVES.has(path.slice(path.lastIndexOf(".") + 1).toLowerCase())));
|
|
293
|
+
}
|
|
294
|
+
// Types the org replaces WHOLESALE: it keeps exactly what the deployed file carries and deletes the
|
|
295
|
+
// rest. These are the only types where omitting an element is destructive rather than merely silent,
|
|
296
|
+
// which is what makes a pre-flight comparison worth its noise.
|
|
297
|
+
// MEASURED on T-Pinto, 2026-07-28, by controlled probe against seeded clones. Not assumption.
|
|
298
|
+
//
|
|
299
|
+
// Layout REPLACE — a 2-field file against a clone of a real 99-field layout left
|
|
300
|
+
// 4 fields and ZERO of its 16 related lists (12 managed-package).
|
|
301
|
+
// 31,578 bytes -> 3,846.
|
|
302
|
+
// PermissionSet REPLACE — a set carrying 1 of 3 field permissions REVOKED the other two.
|
|
303
|
+
// Silent privilege loss, which is worse than content loss.
|
|
304
|
+
// CustomObject MERGE — deploying an object file with its validationRules/ directory
|
|
305
|
+
// absent left all 3 rules intact. Child components are not
|
|
306
|
+
// collateral, which is why CustomObject is not on this list.
|
|
307
|
+
//
|
|
308
|
+
// The earlier "13 modify cases, 0 losses" analysis was measuring careful AUTHORS, not platform
|
|
309
|
+
// behaviour: every one of those files carried the pre-image forward. Absence of observed loss was
|
|
310
|
+
// never evidence of merge, and treating it as such is what made this list unverified for a day.
|
|
311
|
+
const PROVEN_REPLACE_TYPES = new Set(["Layout", "PermissionSet"]);
|
|
312
|
+
// Same suspicion, no evidence either way: never deployed on this machine, so nothing has been
|
|
313
|
+
// measured. Warned about rather than blocked, so a false assumption cannot refuse a valid deploy.
|
|
314
|
+
// Promote to PROVEN only by probing, the way Layout and PermissionSet were.
|
|
315
|
+
const SUSPECTED_REPLACE_TYPES = new Set(["Profile", "SharingRules", "Workflow", "CustomLabels", "MatchingRules", "AssignmentRules", "AutoResponseRules", "EscalationRules"]);
|
|
316
|
+
const FULL_FILE_REPLACE_TYPES = new Set([...PROVEN_REPLACE_TYPES, ...SUSPECTED_REPLACE_TYPES]);
|
|
317
|
+
// Exposed for tests: these encode live measurements, so a change to either set is a claim about
|
|
318
|
+
// platform behaviour and should be backed by a probe.
|
|
319
|
+
export const provenReplaceTypes = () => PROVEN_REPLACE_TYPES;
|
|
320
|
+
export const fullFileReplaceTypes = () => FULL_FILE_REPLACE_TYPES;
|
|
321
|
+
/**
|
|
322
|
+
* Read the earlier authored files a wave request names, so the preview below has something to
|
|
323
|
+
* compare against.
|
|
324
|
+
*
|
|
325
|
+
* FAIL-OPEN, one file at a time: an unreadable earlier file costs its own preview and nothing else.
|
|
326
|
+
* This is a warning that has not been earned by measurement yet (the promotion to a refusal waits on
|
|
327
|
+
* a false-positive rate from a real batch), so it must never be able to interrupt a delivery.
|
|
328
|
+
*/
|
|
329
|
+
export async function loadPredecessorPreImages(revisions) {
|
|
330
|
+
const out = [];
|
|
331
|
+
for (const r of revisions) {
|
|
332
|
+
if (!r.predecessorFile)
|
|
333
|
+
continue;
|
|
334
|
+
const xml = await fs.readFile(r.predecessorFile, "utf8").catch(() => null);
|
|
335
|
+
if (xml === null)
|
|
336
|
+
continue;
|
|
337
|
+
out.push({ typeName: r.typeName, fullName: r.fullName, xml, label: r.predecessor ?? r.predecessorFile });
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* THE DROP CHECK, RUN STATICALLY — before any org read, before any ~45s checkOnly.
|
|
343
|
+
*
|
|
344
|
+
* `PREFLIGHT_REPLACE_DROPS_*` is the real thing and stays the real thing: it compares the org's own
|
|
345
|
+
* pre-image against the authored file, so it knows what is actually there. But it can only run once
|
|
346
|
+
* there IS a pre-image, which means once the earlier state has already been deployed — and for a
|
|
347
|
+
* component this batch delivers twice, the first deploy is minutes of checkOnly away from the moment
|
|
348
|
+
* the omission was written.
|
|
349
|
+
*
|
|
350
|
+
* When the batch itself holds the earlier state as a FILE, that file is a usable stand-in: it is what
|
|
351
|
+
* the org is about to be told to hold, so anything in it that the later file does not carry is
|
|
352
|
+
* content the later deploy would take away. Same comparison, same `findRemovals`, same
|
|
353
|
+
* `isSourceFormatArtifact` noise filter — no second noise catalogue to drift out of step with the
|
|
354
|
+
* first.
|
|
355
|
+
*
|
|
356
|
+
* THREE DELIBERATE NARROWINGS, all of them about not shipping a warning that is usually wrong:
|
|
357
|
+
*
|
|
358
|
+
* TYPE-GATED to `FULL_FILE_REPLACE_TYPES`, exactly like the pre-flight check. Outside that set the
|
|
359
|
+
* org KEEPS what the file omits (CustomObject was measured to: an object file deployed without its
|
|
360
|
+
* validationRules/ directory left all three rules intact), so an omission is not a loss and
|
|
361
|
+
* reporting it would fire on the ordinary create-then-adjust shape every time.
|
|
362
|
+
*
|
|
363
|
+
* UNSTATED ONLY. A facet the later file mentions and carries less of is the author's own decision
|
|
364
|
+
* about their own element; the alarming class — the one this exists for — is a facet the later
|
|
365
|
+
* file never mentions at all.
|
|
366
|
+
*
|
|
367
|
+
* WARNING, never a block, and never ackable: there is nothing to acknowledge, because nothing is
|
|
368
|
+
* being withheld. Promotion to a refusal is gated on a measured false-positive rate from a real
|
|
369
|
+
* batch (origin §8), not on how convincing this comment is.
|
|
370
|
+
*/
|
|
371
|
+
export function previewPredecessorDrops(edits, predecessors) {
|
|
372
|
+
const byKey = new Map(predecessors.map((p) => [`${p.typeName}:${p.fullName}`, p]));
|
|
373
|
+
const out = [];
|
|
374
|
+
for (const edit of edits) {
|
|
375
|
+
if (!FULL_FILE_REPLACE_TYPES.has(edit.typeName))
|
|
376
|
+
continue;
|
|
377
|
+
const earlier = byKey.get(`${edit.typeName}:${edit.fullName}`);
|
|
378
|
+
if (!earlier)
|
|
379
|
+
continue;
|
|
380
|
+
// BOTH sides through the same normalizer, so this compares parsed source against parsed source
|
|
381
|
+
// — quieter than the pre-flight check, which has to compare an org read-back against one of them.
|
|
382
|
+
const before = normalizeIntent(edit.typeName, earlier.xml);
|
|
383
|
+
const after = normalizeIntent(edit.typeName, edit.xml);
|
|
384
|
+
if (!before || !after)
|
|
385
|
+
continue; // unparseable XML is already its own finding
|
|
386
|
+
const dropped = findRemovals(before, after, after).filter((r) => !isSourceFormatArtifact(r) && !r.stated);
|
|
387
|
+
if (dropped.length === 0)
|
|
388
|
+
continue;
|
|
389
|
+
const proven = PROVEN_REPLACE_TYPES.has(edit.typeName);
|
|
390
|
+
out.push({
|
|
391
|
+
rule: "PREVIEW_REPLACE_DROPS_EARLIER",
|
|
392
|
+
severity: "warning",
|
|
393
|
+
component: `${edit.typeName}:${edit.fullName}`,
|
|
394
|
+
message: `This batch changes ${edit.typeName}:${edit.fullName} more than once, and this file does not carry ` +
|
|
395
|
+
`${dropped.length} thing(s) that ${earlier.label} — the earlier file for it in this batch — establishes:\n` +
|
|
396
|
+
renderRemovals(`${edit.typeName}:${edit.fullName}`, dropped) +
|
|
397
|
+
`\n${edit.typeName} ${proven ? "IS full-file replace (MEASURED on a live org, not inferred)" : "may be full-file replace (SUSPECTED — never probed)"}, ` +
|
|
398
|
+
"so the org keeps only what the LAST file deployed carries. This file states the component's complete " +
|
|
399
|
+
"desired end state, which means anything above that is still wanted has to be written into it — nothing " +
|
|
400
|
+
"composes the two files together.\n" +
|
|
401
|
+
"PREVIEW, and a warning only: it compares two authored files, not the org, so it cannot see anything the " +
|
|
402
|
+
"org holds that neither file mentions. The org-grounded check runs at deploy time either way.",
|
|
403
|
+
ackable: false
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* A `ListView` delete may be refused by the org for a reason no static check can see.
|
|
410
|
+
*
|
|
411
|
+
* LIVE ORIGIN (2026-08-03, RV corpus, `ListView:PV_Asset__c.Active_High_Value`): a destructive
|
|
412
|
+
* checkOnly came back `cannot delete last filter` — TWICE. Once bundled with two other deletes, and
|
|
413
|
+
* then again with the ListView alone in its own package, which is what rules out package rollback as
|
|
414
|
+
* the cause. Nothing referenced the view: the analyzer had established that correctly from the
|
|
415
|
+
* projection, and the approval card told the operator on that basis that it would delete cleanly.
|
|
416
|
+
* It was wrong, and the reference reasoning was not the flaw — the org refuses for its own reason.
|
|
417
|
+
*
|
|
418
|
+
* WHAT THE MESSAGE PROBABLY MEANS, and how far that goes. "Filter" is Salesforce's own legacy word
|
|
419
|
+
* for a list view (Classic's `fcf=00B…` parameter; `getFilterId()` is documented as returning "the
|
|
420
|
+
* filter ID of the last used list view"), and this was the ONLY Metadata-API-visible list view on
|
|
421
|
+
* its object — so it reads as "cannot delete the object's last list view" rather than "cannot delete
|
|
422
|
+
* the last filter ROW", which the view's two filter criteria already contradict. That is a
|
|
423
|
+
* hypothesis, not a rule: the exact string is undocumented, the ListView metadata reference states
|
|
424
|
+
* no minimum-filter constraint, and this is one view on one object in one org.
|
|
425
|
+
*
|
|
426
|
+
* So the finding does NOT try to predict the refusal — it cannot, and a delete of a non-last view
|
|
427
|
+
* may well succeed. WARNING, never a block, never ackable: it names the manual-Setup route before a
|
|
428
|
+
* wave is spent discovering it, and the dry run remains the thing that actually answers.
|
|
429
|
+
*/
|
|
430
|
+
export function listViewDeleteWarnings(deletes) {
|
|
431
|
+
return deletes
|
|
432
|
+
.filter((d) => d.typeName === "ListView")
|
|
433
|
+
.map((d) => ({
|
|
434
|
+
rule: "LEMMA_LISTVIEW_DELETE_MAY_REFUSE",
|
|
435
|
+
severity: "warning",
|
|
436
|
+
component: `ListView:${d.fullName}`,
|
|
437
|
+
message: `Salesforce has been measured refusing a ListView delete through the deployment API for a reason ` +
|
|
438
|
+
`UNRELATED to references: "cannot delete last filter" (live-caught 2026-08-03, twice — bundled and ` +
|
|
439
|
+
`then isolated in its own destructive package). "Nothing references it" is therefore not evidence ` +
|
|
440
|
+
`that ${d.fullName} will delete, and no local check can tell you whether this one will. ` +
|
|
441
|
+
`Plan a ListView removal as a MANUAL SETUP TASK by default, and run the delete \`dryRunOnly\` first so ` +
|
|
442
|
+
`the org's verbatim answer lands in deletes.json either way — a non-last view may well delete cleanly, ` +
|
|
443
|
+
`which is why this warns rather than blocks.`,
|
|
444
|
+
ackable: false
|
|
445
|
+
}));
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* The pre-flight replace check's own blind spot, made visible.
|
|
449
|
+
*
|
|
450
|
+
* That check compares the pre-image against the authored intent and names what the replace would
|
|
451
|
+
* drop — but it can only report facets the pre-image CONTAINS. When a read path cannot see a facet
|
|
452
|
+
* at all, the comparison finds nothing to lose and reports the silence as safety. That is not a
|
|
453
|
+
* hypothetical: SOAP readMetadata omits Layout related lists and jsforce renders the omission as
|
|
454
|
+
* `[]`, so on 2026-08-02 the pre-flight walked an Account layout's empty `relatedLists` and would
|
|
455
|
+
* have certified a replace that deleted one (PV corpus Arm A; caught by hand, not by the tool).
|
|
456
|
+
*
|
|
457
|
+
* orgread/layoutSnapshot.ts fixes the read by going to a second source. This covers the residue:
|
|
458
|
+
* when even that source cannot answer, the snapshot says INCOMPLETE instead of guessing, and a
|
|
459
|
+
* full-file replace grounded on it must not be certified. Silence is the defect either way.
|
|
460
|
+
*/
|
|
461
|
+
export function incompleteFacetRisks(edits, manifest) {
|
|
462
|
+
const byKey = new Map(manifest.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
463
|
+
const out = [];
|
|
464
|
+
for (const edit of edits) {
|
|
465
|
+
// A merge type keeps what the file omits, so an unreadable facet is not at risk from THIS
|
|
466
|
+
// deploy; and a create has no pre-image to be incomplete.
|
|
467
|
+
if (!FULL_FILE_REPLACE_TYPES.has(edit.typeName))
|
|
468
|
+
continue;
|
|
469
|
+
const entry = byKey.get(`${edit.typeName}:${edit.fullName}`);
|
|
470
|
+
if (!entry?.found)
|
|
471
|
+
continue;
|
|
472
|
+
const facets = incompleteFacetsOf(entry.errors);
|
|
473
|
+
if (facets.length === 0)
|
|
474
|
+
continue;
|
|
475
|
+
out.push({
|
|
476
|
+
component: `${edit.typeName}:${edit.fullName}`,
|
|
477
|
+
typeName: edit.typeName,
|
|
478
|
+
facets,
|
|
479
|
+
reasons: (entry.errors ?? []).filter((e) => incompleteFacetsOf([e]).length > 0),
|
|
480
|
+
proven: PROVEN_REPLACE_TYPES.has(edit.typeName)
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
return out;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Retrieve the org's CURRENT source XML for the given components into destDir — the same dialect
|
|
487
|
+
* we author, so carrying an existing component forward is an edit, not a JSON transcription (the
|
|
488
|
+
* transcription is where SM301's layout picked up two real divergences; see plan 2026-07-29-002).
|
|
489
|
+
* Fail-open by contract: this is an authoring convenience, never verification — a failed retrieve
|
|
490
|
+
* costs a note, and the canonical JSON under pre/ remains the comparison substrate either way.
|
|
491
|
+
*/
|
|
492
|
+
export async function retrievePreImageSource(targets, targetOrg, apiVersion, destDir) {
|
|
493
|
+
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cc-mcp-preimg-"));
|
|
494
|
+
try {
|
|
495
|
+
// The package directory must EXIST on disk, not just be named in sfdx-project.json. The CLI
|
|
496
|
+
// resolves packageDirectories before it talks to the org and refuses outright when the path is
|
|
497
|
+
// absent (`MissingPackageDirectoryError`, exit 1) — so a temp project carrying only the JSON
|
|
498
|
+
// manifest fails 100% of the time, against any org, on any machine.
|
|
499
|
+
//
|
|
500
|
+
// Live-caught 2026-08-02 on the PV corpus run: EVERY wave that had a pre-image to retrieve lost
|
|
501
|
+
// it, which is every brownfield wave's deployable rollback image (origin R17). It read as
|
|
502
|
+
// environment-specific and was not — `preImageTargets` is pure and fully unit-tested, while the
|
|
503
|
+
// retrieve itself had no test that ran the real command in the real temp layout. That is why
|
|
504
|
+
// preImage.test.ts now drives THIS function through a stub `sf` on PATH.
|
|
505
|
+
await fs.mkdir(path.join(projectDir, PRE_IMAGE_PACKAGE_DIR), { recursive: true });
|
|
506
|
+
await fs.writeFile(path.join(projectDir, "sfdx-project.json"), JSON.stringify({
|
|
507
|
+
packageDirectories: [{ path: PRE_IMAGE_PACKAGE_DIR, default: true }],
|
|
508
|
+
sourceApiVersion: apiVersion
|
|
509
|
+
}), "utf8");
|
|
510
|
+
const args = ["project", "retrieve", "start", "--target-org", targetOrg, "--json"];
|
|
511
|
+
for (const t of targets)
|
|
512
|
+
args.push("--metadata", `${t.typeName}:${t.fullName}`);
|
|
513
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), cwd: projectDir, timeoutMs: 180_000 });
|
|
514
|
+
if (run.timedOut)
|
|
515
|
+
return { ok: false, error: "retrieve killed at 180s timeout" };
|
|
516
|
+
if (run.exitCode !== 0)
|
|
517
|
+
return { ok: false, error: `retrieve exit ${run.exitCode}: ${sfFailureDetail(run)}` };
|
|
518
|
+
// Exit 0 does NOT mean every target came back: a component the org cannot find is reported as a
|
|
519
|
+
// per-file `Failed` state on a status-0 run (measured against 00Dfn00000BFjbBEAT 2026-08-02).
|
|
520
|
+
// Counting targets instead of arrivals would promise a rollback image for components that have
|
|
521
|
+
// none — the same false assurance as the silent failure above, one layer in.
|
|
522
|
+
const outcome = parseRetrieveOutcome(run.stdout, targets);
|
|
523
|
+
const retrievedDir = path.join(projectDir, PRE_IMAGE_PACKAGE_DIR, "main", "default");
|
|
524
|
+
if (outcome.retrieved === 0) {
|
|
525
|
+
// The CLI never creates the directory when nothing lands, so copying it would throw ENOENT and
|
|
526
|
+
// surface as a bare filesystem path. Say what actually happened instead.
|
|
527
|
+
return { ok: false, error: `retrieve returned no source for any of the ${targets.length} target(s)${outcome.problem ? ` — ${outcome.problem}` : ""}` };
|
|
528
|
+
}
|
|
529
|
+
await fs.cp(retrievedDir, destDir, { recursive: true });
|
|
530
|
+
return { ok: true, retrieved: outcome.retrieved, missing: outcome.missing };
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
534
|
+
}
|
|
535
|
+
finally {
|
|
536
|
+
await fs.rm(projectDir, { recursive: true, force: true });
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
/** Package directory of the throwaway sfdx project the pre-image retrieve runs in. */
|
|
540
|
+
const PRE_IMAGE_PACKAGE_DIR = "force-app";
|
|
541
|
+
/**
|
|
542
|
+
* The actionable line out of a failed `sf --json` run.
|
|
543
|
+
*
|
|
544
|
+
* `--json` puts the error payload on STDOUT and leaves stderr EMPTY (measured on @salesforce/cli
|
|
545
|
+
* 2.137.7), so a stderr tail — the obvious thing to attach — carries nothing. The live failure
|
|
546
|
+
* reported only "retrieve exit 1", which told the supervisor there was no rollback image but not
|
|
547
|
+
* one thing they could do about it.
|
|
548
|
+
*/
|
|
549
|
+
function sfFailureDetail(run) {
|
|
550
|
+
try {
|
|
551
|
+
const parsed = JSON.parse(run.stdout);
|
|
552
|
+
const message = typeof parsed.message === "string" ? parsed.message : "";
|
|
553
|
+
const name = typeof parsed.name === "string" ? parsed.name : "";
|
|
554
|
+
if (message)
|
|
555
|
+
return name ? `${name}: ${message}` : message;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Not JSON — a crash before the CLI's own error handler, or a non-CLI failure. Fall through.
|
|
559
|
+
}
|
|
560
|
+
const tail = (run.stderr || run.stdout).trim().split("\n").slice(-3).join(" | ").slice(0, 400);
|
|
561
|
+
return tail || "no output";
|
|
562
|
+
}
|
|
563
|
+
/** Which of the requested targets the retrieve actually produced source for. */
|
|
564
|
+
function parseRetrieveOutcome(stdout, targets) {
|
|
565
|
+
let files = [];
|
|
566
|
+
let problem = "";
|
|
567
|
+
try {
|
|
568
|
+
const parsed = JSON.parse(stdout);
|
|
569
|
+
if (Array.isArray(parsed.result?.files))
|
|
570
|
+
files = parsed.result.files;
|
|
571
|
+
const messages = parsed.result?.messages;
|
|
572
|
+
const first = Array.isArray(messages) ? messages[0] : messages;
|
|
573
|
+
if (first && typeof first === "object" && typeof first.problem === "string") {
|
|
574
|
+
problem = first.problem;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
// Unparseable success output: trust the exit code and claim every target, which is what this
|
|
579
|
+
// did before it read the payload at all.
|
|
580
|
+
return { retrieved: targets.length, missing: [], problem: "" };
|
|
581
|
+
}
|
|
582
|
+
const arrived = new Set(files
|
|
583
|
+
.filter((f) => f.state !== "Failed")
|
|
584
|
+
.map((f) => `${String(f.type)}:${String(f.fullName)}`));
|
|
585
|
+
const missing = targets.filter((t) => !arrived.has(`${t.typeName}:${t.fullName}`));
|
|
586
|
+
return { retrieved: targets.length - missing.length, missing, problem };
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Tooling objects that carry a usable LastModifiedDate, probed live against T-Pinto 2026-07-31.
|
|
590
|
+
*
|
|
591
|
+
* The `id` column differs per type and several have no `Name` at all, so each entry names the
|
|
592
|
+
* column that identifies the row. EntityDefinition is deliberately ABSENT: it exposes
|
|
593
|
+
* LastModifiedDate but the org returns null for it, so object-level changes are found via
|
|
594
|
+
* CustomObject instead.
|
|
595
|
+
*
|
|
596
|
+
* A type missing from this list is simply unscanned — the report says which types it covered rather
|
|
597
|
+
* than implying it looked everywhere.
|
|
598
|
+
*/
|
|
599
|
+
const CHANGE_SCAN_TYPES = [
|
|
600
|
+
// `parentField` qualifies the row with its OBJECT. Without it the scan selects a bare
|
|
601
|
+
// DeveloperName, so a foreign `Contact.Status__c` is indistinguishable from our
|
|
602
|
+
// `Account.Status__c` and gets suppressed — and `Status__c`, `Name__c`, `Active__c`,
|
|
603
|
+
// `Description__c` are exactly the names a delivery deploys most. That blinded the scan to a
|
|
604
|
+
// common name across the WHOLE org for the duration of the wave. Probed live 2026-07-31:
|
|
605
|
+
// EntityDefinition.QualifiedApiName returns the object API name on both types.
|
|
606
|
+
{ type: "CustomField", nameField: "DeveloperName", metadataType: "CustomField", parentField: "EntityDefinition.QualifiedApiName" },
|
|
607
|
+
{ type: "ValidationRule", nameField: "ValidationName", metadataType: "ValidationRule", parentField: "EntityDefinition.QualifiedApiName" },
|
|
608
|
+
{ type: "Layout", nameField: "Name", metadataType: "Layout" },
|
|
609
|
+
{ type: "CustomObject", nameField: "DeveloperName", metadataType: "CustomObject" },
|
|
610
|
+
{ type: "CompactLayout", nameField: "DeveloperName", metadataType: "CompactLayout" },
|
|
611
|
+
// Probed live against vfppartial 2026-07-31 with the exact query shape above (NamespacePrefix
|
|
612
|
+
// filter + window), all three returning cleanly. fc19eae's message said Flow and CustomTab
|
|
613
|
+
// "carry a usable LastModifiedDate" and then shipped without them.
|
|
614
|
+
//
|
|
615
|
+
// Flow is scanned as FlowDefinition, not Flow: `Flow` rows are VERSIONS and have no
|
|
616
|
+
// DeveloperName at all (INVALID_FIELD), while FlowDefinition carries the API name the metadata
|
|
617
|
+
// type is addressed by. CustomTab needs DeveloperName rather than Name for the same reason
|
|
618
|
+
// precedent.ts already documents for object tabs.
|
|
619
|
+
{ type: "FlowDefinition", nameField: "DeveloperName", metadataType: "Flow" },
|
|
620
|
+
{ type: "CustomTab", nameField: "DeveloperName", metadataType: "CustomTab" },
|
|
621
|
+
{ type: "QuickActionDefinition", nameField: "DeveloperName", metadataType: "QuickAction" },
|
|
622
|
+
// PROFILE IS DELIBERATELY ABSENT, and this is the gap worth knowing about: with PermissionSet
|
|
623
|
+
// filtered to IsOwnedByProfile = false, profile and field-level-security changes are structurally
|
|
624
|
+
// invisible to this scan. Two reasons, both checked rather than assumed. It has no
|
|
625
|
+
// NamespacePrefix column (INVALID_FIELD against the query above), so it cannot use this shape at
|
|
626
|
+
// all. And profiles are touched by so much routine org activity that including them would
|
|
627
|
+
// reproduce the 72/76 noise that got profile-owned permission sets excluded — an alarm that fires
|
|
628
|
+
// on every wave teaches operators to ignore the one that matters.
|
|
629
|
+
{
|
|
630
|
+
type: "PermissionSet",
|
|
631
|
+
nameField: "Name",
|
|
632
|
+
metadataType: "PermissionSet",
|
|
633
|
+
// Salesforce keeps a hidden PermissionSet row per PROFILE, and they churn constantly: a live
|
|
634
|
+
// 10-minute window on T-Pinto returned 76 rows of which 72 were profile-owned, with generated
|
|
635
|
+
// names like `X00ex00000018ozT_128_09_43_34_1`. Unfiltered, this one type would have buried
|
|
636
|
+
// every genuine candidate under machine noise. The 4 that survive the filter were the real
|
|
637
|
+
// permission sets the EV arms deployed.
|
|
638
|
+
extraWhere: "IsOwnedByProfile = false"
|
|
639
|
+
}
|
|
640
|
+
];
|
|
641
|
+
/**
|
|
642
|
+
* Ask the org what moved during the mutation window, rather than reading every sibling component
|
|
643
|
+
* before and after on the chance that one of them did.
|
|
644
|
+
*
|
|
645
|
+
* Replaces the parent-object projection expansion (plan -002). Measured: the expansion read 4.6
|
|
646
|
+
* components per component touched and found one side change in 70 bundles; this is one Tooling
|
|
647
|
+
* query per type — 2.3s org-wide on T-Pinto, the same cost as querying a single object — and it
|
|
648
|
+
* also sees objects that were never in scope at all, which the expansion structurally could not.
|
|
649
|
+
*
|
|
650
|
+
* `NamespacePrefix = null` skips managed components: they cannot change as a result of our deploys,
|
|
651
|
+
* and on T-Pinto's Account they are 192 of 354 fields.
|
|
652
|
+
*
|
|
653
|
+
* IMPORTANT — this establishes "changed during your window", never "caused by your deploy". Live
|
|
654
|
+
* evidence that the distinction is real: on 2026-07-29 two EV arms deployed to the same org four
|
|
655
|
+
* minutes apart, so each would appear in the other's window. Causation stays the intent check's job.
|
|
656
|
+
*/
|
|
657
|
+
// Salesforce's Tooling rows do NOT name components the way a projection key does, and matching the
|
|
658
|
+
// two by string surgery is where this check accused a wave of being someone else's work.
|
|
659
|
+
//
|
|
660
|
+
// Two shapes broke it, both live-caught on CF63 w4, and both reported the wave's OWN components back
|
|
661
|
+
// as "changed in the org during this deploy that this wave did not deploy":
|
|
662
|
+
// CustomObject.DeveloperName → "CF63_Intake_Request" vs key "CustomObject:CF63_Intake_Request__c"
|
|
663
|
+
// Layout.Name → "CF63 Intake Request Layout" vs key "Layout:CF63_Intake_Request__c-CF63 Intake Request Layout"
|
|
664
|
+
// DeveloperName carries no `__c`, and the Layout key joins object and label with a DASH while the
|
|
665
|
+
// old test only knew about a dot. Neither matched, so both became phantom side changes — and
|
|
666
|
+
// sideChanges is load-bearing: any entry downgrades DIFF_VERIFIED to DELIVERED_WITH_DRIFT. A wave
|
|
667
|
+
// whose intent report was spotless (32/6/11/1 matched, zero mismatches) still read as drifted.
|
|
668
|
+
//
|
|
669
|
+
// Compared against every segment of the key rather than the whole, with the custom-object suffix
|
|
670
|
+
// normalized off both sides. Errs toward silence over false accusation, as before: a real
|
|
671
|
+
// concurrent change that happens to share a leaf name with something we deployed is missed, which
|
|
672
|
+
// is the cheaper error — the intent check still owns causation.
|
|
673
|
+
// Scoped BY TYPE. Normalizing the suffix away makes names collide across types that never used to:
|
|
674
|
+
// a wave deploying `CustomObject:CF63_Intake_Request__c` would otherwise silence a foreign
|
|
675
|
+
// `CustomField` whose DeveloperName is `CF63_Intake_Request` — which is exactly what the Salesforce
|
|
676
|
+
// UI names a lookup to that object. Silencing the one check watching for a concurrent admin is a
|
|
677
|
+
// worse failure than the false positive this function was written to remove, so the comparison only
|
|
678
|
+
// considers keys of the row's own type.
|
|
679
|
+
//
|
|
680
|
+
// ONE cross-type match survives, because it is real rather than coincidental: deploying a CHILD
|
|
681
|
+
// bumps its parent object's LastModifiedDate, so a CustomObject row matching the object half of any
|
|
682
|
+
// key is ours. That relationship is what the original dot-prefix test was reaching for.
|
|
683
|
+
const CUSTOM_SUFFIX_RE = /__(c|mdt|e|b|x|kav|share|history|feed|tag|changeevent)$/i;
|
|
684
|
+
const bareName = (s) => s.replace(CUSTOM_SUFFIX_RE, "").toLowerCase();
|
|
685
|
+
export function changeScanNameIsOurs(ours, name, metadataType, parentObject) {
|
|
686
|
+
const want = bareName(name);
|
|
687
|
+
// When the scan knows the row's OBJECT, the leaf name alone is no longer enough to claim the row
|
|
688
|
+
// as ours. Deploying `Account.Status__c` used to suppress a concurrent `Contact.Status__c` — and
|
|
689
|
+
// a foreign change to a same-named field is not a coincidence worth tolerating, it is the single
|
|
690
|
+
// most likely collision on a real org.
|
|
691
|
+
const wantParent = parentObject ? bareName(parentObject) : undefined;
|
|
692
|
+
for (const key of ours) {
|
|
693
|
+
const colon = key.indexOf(":");
|
|
694
|
+
const keyType = key.slice(0, colon);
|
|
695
|
+
const leaf = key.slice(colon + 1);
|
|
696
|
+
// A key is either bare (`Obj__c`), dot-qualified (`Obj__c.Field__c`) or dash-qualified
|
|
697
|
+
// (`Obj__c-Layout Label`). API names contain neither separator, so the FIRST one splits it.
|
|
698
|
+
const dot = leaf.indexOf(".");
|
|
699
|
+
const dash = leaf.indexOf("-");
|
|
700
|
+
const parent = dot >= 0 ? leaf.slice(0, dot) : dash >= 0 ? leaf.slice(0, dash) : leaf;
|
|
701
|
+
if (metadataType === "CustomObject" && bareName(parent) === want)
|
|
702
|
+
return true;
|
|
703
|
+
if (metadataType && keyType !== metadataType)
|
|
704
|
+
continue;
|
|
705
|
+
// Qualified comparison when the object is known: both halves must agree, so a same-named field
|
|
706
|
+
// on a different object stays visible as a side change.
|
|
707
|
+
if (wantParent !== undefined) {
|
|
708
|
+
const child = dot >= 0 ? leaf.slice(dot + 1) : dash >= 0 ? leaf.slice(dash + 1) : leaf;
|
|
709
|
+
if (bareName(parent) === wantParent && bareName(child) === want)
|
|
710
|
+
return true;
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const segments = [leaf, parent];
|
|
714
|
+
if (dot >= 0)
|
|
715
|
+
segments.push(leaf.slice(dot + 1));
|
|
716
|
+
if (dash >= 0)
|
|
717
|
+
segments.push(leaf.slice(dash + 1));
|
|
718
|
+
if (segments.some((s) => bareName(s) === want))
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
return false;
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* What a facet update is worth SAYING once the post-projection has looked.
|
|
725
|
+
*
|
|
726
|
+
* The CRUD Metadata API's `success: true` acknowledges the REQUEST — Salesforce returns it for
|
|
727
|
+
* facets it then silently ignores. Live-caught CF63 w1: `sharingModel` reported success twice while
|
|
728
|
+
* `EntityDefinition.InternalSharingModel` stayed ReadWrite. The read-back caught it, but the same
|
|
729
|
+
* response still carried `status: "updated"` beside that mismatch, so one payload made two
|
|
730
|
+
* contradictory claims and only the operator noticing the disagreement kept the report honest.
|
|
731
|
+
*
|
|
732
|
+
* So the word is withheld until there is evidence for it, and when there is not, the status says
|
|
733
|
+
* what the org actually reads and what to do about it — a summary quoting this line alone must not
|
|
734
|
+
* be able to mislead.
|
|
735
|
+
*/
|
|
736
|
+
export function facetVerifiedStatus(mismatches) {
|
|
737
|
+
if (mismatches.length === 0)
|
|
738
|
+
return "verified";
|
|
739
|
+
const detail = mismatches.map((m) => `${m.path}=${m.observed} (asked for ${m.intended})`).join(", ");
|
|
740
|
+
return (`NOT APPLIED — the org accepted the request but still reads ${detail}. ` +
|
|
741
|
+
`Salesforce silently ignores some facets on this path, so this is USUALLY the org's answer: change it in Setup ` +
|
|
742
|
+
`or find another mechanism, and do NOT re-send blindly. It is a SINGLE read, taken immediately after the write ` +
|
|
743
|
+
`— the read-back no longer waits (the backoff settled zero of the 17 waves it ever fired on), so a facet ` +
|
|
744
|
+
`Salesforce recalculates asynchronously can read stale here. The notes above say which kind this is; ` +
|
|
745
|
+
`re-project the scope to settle it either way.`);
|
|
746
|
+
}
|
|
747
|
+
export async function scanChangedInWindow(reader, window, ours,
|
|
748
|
+
/**
|
|
749
|
+
* Components that MUST show up in the scan if the window is aimed correctly — edits and facet
|
|
750
|
+
* updates, never DELETES.
|
|
751
|
+
*
|
|
752
|
+
* A deleted component has no row left to carry a LastModifiedDate, so a delete-only wave would
|
|
753
|
+
* witness nothing and report every window as suspect. Defaults to `ours` for callers that do not
|
|
754
|
+
* separate them, which is safe only when the wave contains no deletes.
|
|
755
|
+
*/
|
|
756
|
+
witnesses = ours) {
|
|
757
|
+
const candidates = [];
|
|
758
|
+
const typesCovered = [];
|
|
759
|
+
const notes = [];
|
|
760
|
+
// Types this wave actually touched, restricted to ones the scan can see. These are the components
|
|
761
|
+
// whose presence in the results proves the window is aimed correctly.
|
|
762
|
+
const ourTypes = new Set([...witnesses].map((key) => key.slice(0, key.indexOf(":"))));
|
|
763
|
+
const witnessTypes = new Set(CHANGE_SCAN_TYPES.filter((t) => ourTypes.has(t.metadataType)).map((t) => t.type));
|
|
764
|
+
const witnessed = new Set();
|
|
765
|
+
if (!window.start || !window.end) {
|
|
766
|
+
return { candidates, typesCovered, windowSuspect: false, notes: ["no mutation window recorded — scan skipped"] };
|
|
767
|
+
}
|
|
768
|
+
for (const { type, nameField, metadataType, extraWhere, parentField } of CHANGE_SCAN_TYPES) {
|
|
769
|
+
try {
|
|
770
|
+
const res = await reader.toolingQuery(`SELECT ${nameField}${parentField ? `, ${parentField}` : ""}, LastModifiedDate FROM ${type} ` +
|
|
771
|
+
`WHERE NamespacePrefix = null${extraWhere ? ` AND ${extraWhere}` : ""} ` +
|
|
772
|
+
`AND LastModifiedDate >= ${window.start} AND LastModifiedDate <= ${window.end}`);
|
|
773
|
+
typesCovered.push(type);
|
|
774
|
+
for (const row of res.records) {
|
|
775
|
+
const name = row[nameField];
|
|
776
|
+
if (typeof name !== "string" || !name)
|
|
777
|
+
continue;
|
|
778
|
+
// A dotted Tooling path comes back NESTED, not flattened:
|
|
779
|
+
// `EntityDefinition.QualifiedApiName` arrives as `{EntityDefinition: {QualifiedApiName}}`.
|
|
780
|
+
const parent = parentField
|
|
781
|
+
? parentField.split(".").reduce((node, seg) => node?.[seg], row)
|
|
782
|
+
: undefined;
|
|
783
|
+
const qualified = typeof parent === "string" && parent ? `${parent}.${name}` : name;
|
|
784
|
+
if (changeScanNameIsOurs(ours, name, metadataType, typeof parent === "string" ? parent : undefined)) {
|
|
785
|
+
witnessed.add(type);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
candidates.push(`${metadataType}:${qualified}`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
notes.push(`change scan skipped ${type}: ${error instanceof Error ? error.message : String(error)}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// Every type that both carried one of our components AND was successfully queried should have
|
|
797
|
+
// returned at least one of ours. A miss means the window is not over the interval the deploy
|
|
798
|
+
// actually happened in — clock skew being the likely cause, since the window comes from this
|
|
799
|
+
// machine's clock and LastModifiedDate comes from the org's.
|
|
800
|
+
const missedWitnesses = [...witnessTypes].filter((type) => typesCovered.includes(type) && !witnessed.has(type));
|
|
801
|
+
const windowSuspect = missedWitnesses.length > 0;
|
|
802
|
+
if (windowSuspect) {
|
|
803
|
+
notes.push(`The change scan's window did not contain this wave's OWN changes to ${missedWitnesses.join(", ")}, ` +
|
|
804
|
+
`so it is not looking at the interval the deploy happened in — most likely clock skew between this machine ` +
|
|
805
|
+
`and the org (the window is stamped locally; LastModifiedDate is stamped by Salesforce). ` +
|
|
806
|
+
`Treat "no concurrent changes found" as unproven for this wave rather than as a clean result.`);
|
|
807
|
+
}
|
|
808
|
+
return { candidates: [...new Set(candidates)].sort(), typesCovered, windowSuspect, notes };
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* True/false history-tracking state for every edited CustomField, read from Tooling
|
|
812
|
+
* `FieldDefinition.IsFieldHistoryTracked` — the only surface that reports it.
|
|
813
|
+
*
|
|
814
|
+
* EVERY edited field, not only the ones whose intent STATES trackHistory. That narrower filter was
|
|
815
|
+
* right for the job this probe was first written for (verifying a stated `trackHistory`) and wrong
|
|
816
|
+
* for the one the EV-31…40 run exposed: a DIFF_VERIFIED deploy switched history tracking OFF on a
|
|
817
|
+
* field whose file never mentioned it. The facet is invisible to the Metadata API — omitted whatever
|
|
818
|
+
* its value — so the pre/post image comparison that catches every other unstated change is
|
|
819
|
+
* structurally blind to this one, and a probe that only looks where the author already looked cannot
|
|
820
|
+
* see it either. Called on BOTH sides of the deploy; a value that moved with the intent silent is
|
|
821
|
+
* reported as an unstated change.
|
|
822
|
+
*
|
|
823
|
+
* Costs one Tooling query per OBJECT per side, and nothing at all on a wave that edits no fields.
|
|
824
|
+
* Keyed by the edit's fullName (`Object.Field__c`) because that is what the caller holds.
|
|
825
|
+
*
|
|
826
|
+
* Fail-open by contract: a probe that throws returns an empty map — every field falls back to the
|
|
827
|
+
* projection's (absent) value and no tracking change can be claimed. That is the pre-existing
|
|
828
|
+
* false-drift behaviour: noisy, never wrong in the dangerous direction.
|
|
829
|
+
*/
|
|
830
|
+
export async function probeFieldHistoryTracking(reader, edits) {
|
|
831
|
+
const wanted = edits.filter((e) => e.typeName === "CustomField" && e.fullName.includes("."));
|
|
832
|
+
const out = new Map();
|
|
833
|
+
if (wanted.length === 0)
|
|
834
|
+
return out;
|
|
835
|
+
const byObject = new Map();
|
|
836
|
+
for (const e of wanted) {
|
|
837
|
+
const [obj, field] = [e.fullName.slice(0, e.fullName.indexOf(".")), e.fullName.slice(e.fullName.indexOf(".") + 1)];
|
|
838
|
+
if (!obj || !field)
|
|
839
|
+
continue;
|
|
840
|
+
(byObject.get(obj) ?? byObject.set(obj, []).get(obj)).push(field);
|
|
841
|
+
}
|
|
842
|
+
for (const [obj, fields] of byObject) {
|
|
843
|
+
try {
|
|
844
|
+
const quoted = fields.map((f) => `'${f.replace(/'/g, "\\'")}'`).join(",");
|
|
845
|
+
const res = await reader.toolingQuery(`SELECT QualifiedApiName, IsFieldHistoryTracked FROM FieldDefinition ` +
|
|
846
|
+
`WHERE EntityDefinition.QualifiedApiName = '${obj.replace(/'/g, "\\'")}' AND QualifiedApiName IN (${quoted})`);
|
|
847
|
+
for (const r of res.records)
|
|
848
|
+
out.set(`${obj}.${r.QualifiedApiName}`, Boolean(r.IsFieldHistoryTracked));
|
|
849
|
+
}
|
|
850
|
+
catch {
|
|
851
|
+
// Probe unavailable for this object — leave those fields to the projection's view.
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Did this deploy move history tracking on a field whose file never mentioned it?
|
|
858
|
+
*
|
|
859
|
+
* The generic pre/post comparison cannot answer this — the Metadata API omits trackHistory from
|
|
860
|
+
* every read-back — so the two Tooling probes stand in for the pre- and post-image of that one facet.
|
|
861
|
+
*
|
|
862
|
+
* Silent when the intent STATES trackHistory: compareIntent already verifies that against the same
|
|
863
|
+
* probe, and reporting it here as well would make one finding look like two. Silent when either side
|
|
864
|
+
* is unknown, which covers both a create (the field had no pre-deploy row) and a failed probe.
|
|
865
|
+
*/
|
|
866
|
+
export function unstatedTrackingChange(edit, intent, before, after) {
|
|
867
|
+
if (edit.typeName !== "CustomField")
|
|
868
|
+
return null;
|
|
869
|
+
if (intent && "trackHistory" in intent)
|
|
870
|
+
return null;
|
|
871
|
+
const was = before.get(edit.fullName);
|
|
872
|
+
const now = after.get(edit.fullName);
|
|
873
|
+
if (was === undefined || now === undefined || was === now)
|
|
874
|
+
return null;
|
|
875
|
+
// Rendered the way diffMetadata renders a coerced leaf, so a reader cannot tell which check
|
|
876
|
+
// produced which line — they are answering the same question about the same component.
|
|
877
|
+
return { path: "metadata.trackHistory", before: JSON.stringify(String(was)), after: JSON.stringify(String(now)) };
|
|
878
|
+
}
|
|
879
|
+
export function deriveScope(edits, deletes) {
|
|
880
|
+
const objects = new Set();
|
|
881
|
+
const components = [];
|
|
882
|
+
for (const item of [...edits, ...deletes]) {
|
|
883
|
+
if (item.typeName === "CustomObject")
|
|
884
|
+
objects.add(item.fullName);
|
|
885
|
+
else if (["CustomField", "ValidationRule", "ListView", "CompactLayout", "RecordType"].includes(item.typeName)) {
|
|
886
|
+
const obj = item.fullName.split(".")[0];
|
|
887
|
+
if (obj)
|
|
888
|
+
objects.add(obj);
|
|
889
|
+
if (["ListView", "CompactLayout", "RecordType"].includes(item.typeName)) {
|
|
890
|
+
components.push({ typeName: item.typeName, fullName: item.fullName });
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
else
|
|
894
|
+
components.push({ typeName: item.typeName, fullName: item.fullName });
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
...(objects.size ? { objects: [...objects].sort() } : {}),
|
|
898
|
+
...(components.length ? { components } : {})
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
// THE DEFAULT since plan -002. (It began as the fallback for when deriveScope's parent-object expansion tripped the projection's component
|
|
902
|
+
// cap. Live-caught (T-Pinto, 2026-07-29): deleting 28 debris CustomFields on Account was refused
|
|
903
|
+
// because Account's expansion resolves to 342 components — the tool could not delete a FIELD off
|
|
904
|
+
// any large standard object at all, and the work went through a raw destructive deploy with no
|
|
905
|
+
// evidence bundle. This scope names exactly the touched components instead: intent verification
|
|
906
|
+
// and the pre/post diff still cover everything the bundle changes; what is given up is sibling
|
|
907
|
+
// side-change detection and the pre-projection's referential lint breadth (session-level tokens
|
|
908
|
+
// still apply). deriveScope survives behind direct_apply's `wideObjectScope` flag for a deliberate
|
|
909
|
+
// whole-object before/after; it is no longer what an ordinary wave pays for.
|
|
910
|
+
export function narrowScope(edits, deletes, facetObjects) {
|
|
911
|
+
const components = [];
|
|
912
|
+
const seen = new Set();
|
|
913
|
+
const push = (typeName, fullName) => {
|
|
914
|
+
const key = `${typeName}:${fullName}`;
|
|
915
|
+
if (seen.has(key))
|
|
916
|
+
return;
|
|
917
|
+
seen.add(key);
|
|
918
|
+
components.push({ typeName, fullName });
|
|
919
|
+
};
|
|
920
|
+
for (const item of [...edits, ...deletes])
|
|
921
|
+
push(item.typeName, item.fullName);
|
|
922
|
+
for (const obj of facetObjects)
|
|
923
|
+
push("CustomObject", obj);
|
|
924
|
+
return { components };
|
|
925
|
+
}
|
|
926
|
+
// The org MATERIALIZES default:"false" and label=fullName onto every picklist/value-set
|
|
927
|
+
// entry on read-back (stress-caught case E, 2026-07-22; GVS customValue, 6.5.1 run). Intent
|
|
928
|
+
// omitting them would false-mismatch the whole array — canonicalize the intent side to match.
|
|
929
|
+
function materializePicklistEntries(list) {
|
|
930
|
+
const values = Array.isArray(list) ? list : [list];
|
|
931
|
+
return values.map((v) => {
|
|
932
|
+
if (!v || typeof v !== "object")
|
|
933
|
+
return v;
|
|
934
|
+
const entry = { ...v };
|
|
935
|
+
if (entry.default === undefined)
|
|
936
|
+
entry.default = "false";
|
|
937
|
+
if (entry.label === undefined && typeof entry.fullName === "string")
|
|
938
|
+
entry.label = entry.fullName;
|
|
939
|
+
return entry;
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
// Intent = the submitted XML parsed to JSON and pushed through the SAME normalizations
|
|
943
|
+
// the projection applies to org read-backs, so intent-vs-observed compares like with like.
|
|
944
|
+
export function normalizeIntent(typeName, xml) {
|
|
945
|
+
const parsed = parseMetadataXml(xml);
|
|
946
|
+
if (!parsed)
|
|
947
|
+
return null;
|
|
948
|
+
if (typeName === "CustomField") {
|
|
949
|
+
const md = normalizeCustomFieldMetadata(parsed.body);
|
|
950
|
+
// The defaultValue projection (EXT-2040: picklist default lives on the VALUE)
|
|
951
|
+
// happens in the snapshot READER, not the shared normalizer — mirror it here so
|
|
952
|
+
// intent compares like-with-like against the projected read-back.
|
|
953
|
+
if (md && md.defaultValue === undefined) {
|
|
954
|
+
const projected = valueSetDefaultValue(md.valueSet);
|
|
955
|
+
if (projected !== undefined)
|
|
956
|
+
md.defaultValue = projected;
|
|
957
|
+
}
|
|
958
|
+
const valueSet = md?.valueSet;
|
|
959
|
+
const def = valueSet?.valueSetDefinition;
|
|
960
|
+
if (def?.value !== undefined)
|
|
961
|
+
def.value = materializePicklistEntries(def.value);
|
|
962
|
+
// fieldManageability is a design-time-only facet for CustomMetadata (__mdt) fields;
|
|
963
|
+
// an unmanaged org OMITS it on read-back, so comparing it always false-mismatches
|
|
964
|
+
// (6.5.1 run: 3 __mdt fields drifted "DeveloperControlled" vs absent). It carries no
|
|
965
|
+
// deliverable meaning here — drop it from intent so it never manufactures drift.
|
|
966
|
+
if (md && "fieldManageability" in md)
|
|
967
|
+
delete md.fieldManageability;
|
|
968
|
+
return md;
|
|
969
|
+
}
|
|
970
|
+
if (typeName === "GlobalValueSet") {
|
|
971
|
+
// Same value materialization as CustomField picklists — the org stamps default:"false"
|
|
972
|
+
// + label onto every customValue on read-back (6.5.1 run: CF66_Case_Category_GVS drifted).
|
|
973
|
+
const body = parsed.body;
|
|
974
|
+
if (body.customValue !== undefined)
|
|
975
|
+
body.customValue = materializePicklistEntries(body.customValue);
|
|
976
|
+
return body;
|
|
977
|
+
}
|
|
978
|
+
return parsed.body;
|
|
979
|
+
}
|
|
980
|
+
const INTENT_IDENTITY_KEYS = ["fullName", "name", "label", "field", "relatedList", "apiName", "tab"];
|
|
981
|
+
// Subset check: every leaf the intent states must hold in the observed metadata.
|
|
982
|
+
// Org-added facets are ignored; orderOnly array differences are tolerated and counted.
|
|
983
|
+
export function compareIntent(intent, observed) {
|
|
984
|
+
if (!observed)
|
|
985
|
+
return { matched: 0, mismatches: [{ path: "metadata", intended: "(component)", observed: "(absent)" }], orderOnly: 0 };
|
|
986
|
+
let matched = 0;
|
|
987
|
+
let orderOnly = 0;
|
|
988
|
+
const mismatches = [];
|
|
989
|
+
const walk = (want, have, at) => {
|
|
990
|
+
// SAME CLASS as the boolean-false rule below, but the platform default is a NAMED value rather
|
|
991
|
+
// than false, so that rule never caught it. Live-caught CF74 wave 3: the author states
|
|
992
|
+
// formulaTreatBlanksAs=BlankAsBlank, the org drops the element because it IS the default, and
|
|
993
|
+
// the wave reported DELIVERED_WITH_DRIFT on a field that was perfectly correct.
|
|
994
|
+
//
|
|
995
|
+
// This must be CANONICALIZED, not merely tolerated: the element is omitted permanently, so no
|
|
996
|
+
// amount of re-reading would ever resolve it. (When a backoff still existed here, an
|
|
997
|
+
// uncatalogued entry burned the whole sleep on every apply and reported drift anyway.)
|
|
998
|
+
const leafName = at.slice(at.lastIndexOf(".") + 1);
|
|
999
|
+
if ((have === undefined || have === null) &&
|
|
1000
|
+
leafName in PLATFORM_DEFAULT_WHEN_OMITTED &&
|
|
1001
|
+
// Deep, not ===: rejectBehavior's default is an OBJECT, which reference equality never matches.
|
|
1002
|
+
canonJson(PLATFORM_DEFAULT_WHEN_OMITTED[leafName]) === canonJson(want)) {
|
|
1003
|
+
matched += 1;
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
// INVERSE of the materialization class (live-caught 6.5.4 wave 1): the author states a
|
|
1007
|
+
// boolean explicitly FALSE (externalId, trackHistory, trackTrending, unique, …) and the org
|
|
1008
|
+
// DROPS it on read-back because it equals the platform default. Absent means false, so this
|
|
1009
|
+
// is agreement, not drift. An intended TRUE that comes back absent stays a real mismatch.
|
|
1010
|
+
if ((want === false || want === "false") && (have === undefined || have === null)) {
|
|
1011
|
+
matched += 1;
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
// Benchmark-caught (2026-07-22, 11/26 units): the XML parser collapses a single
|
|
1015
|
+
// repeatable child to an OBJECT while the org read-back always returns an ARRAY
|
|
1016
|
+
// (and materializes empty ones). Normalize the singleton/empty representations
|
|
1017
|
+
// before comparing — they are the same metadata.
|
|
1018
|
+
if (Array.isArray(have) && !Array.isArray(want)) {
|
|
1019
|
+
if (have.length === 1)
|
|
1020
|
+
return walk(want, have[0], at);
|
|
1021
|
+
if (have.length === 0 && (want === undefined || want === null)) {
|
|
1022
|
+
matched += 1;
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
if (Array.isArray(want) && !Array.isArray(have) && want.length === 1) {
|
|
1027
|
+
return walk(want[0], have, at);
|
|
1028
|
+
}
|
|
1029
|
+
// BOTH arrays. Previously this fell straight through to a whole-array structural diff, which
|
|
1030
|
+
// is NOT a subset check — so ONE org-injected key inside ONE element failed the entire
|
|
1031
|
+
// collection. That single gap produced most of CF77's false drift: the platform injects
|
|
1032
|
+
// `processMetadataValues: []` into every Flow inputAssignment, and both Flows plus the Layout
|
|
1033
|
+
// reported DELIVERED_WITH_DRIFT on content that matched the author's intent exactly.
|
|
1034
|
+
//
|
|
1035
|
+
// Matched by identity where the elements carry one, so a reorder on read-back is not a
|
|
1036
|
+
// difference either; positionally only when there is nothing to key on.
|
|
1037
|
+
if (isEmptyish(want) && isEmptyish(have)) {
|
|
1038
|
+
matched += 1;
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (Array.isArray(want) && Array.isArray(have)) {
|
|
1042
|
+
// Same members, different order. The org re-sorts collections freely on read-back (CF77's
|
|
1043
|
+
// console app came back with its six workspace tabs reordered), and a reorder is explicitly
|
|
1044
|
+
// tolerated everywhere else in this comparator — so recognise it before doing any positional
|
|
1045
|
+
// work, which would otherwise manufacture one mismatch per shifted element.
|
|
1046
|
+
if (canonJson([...want].map(canonJson).sort()) === canonJson([...have].map(canonJson).sort())) {
|
|
1047
|
+
orderOnly += 1;
|
|
1048
|
+
matched += 1;
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const key = INTENT_IDENTITY_KEYS.find((k) => want.every((e) => isPlainObj(e) && typeof e[k] === "string") &&
|
|
1052
|
+
have.every((e) => isPlainObj(e) && typeof e[k] === "string") &&
|
|
1053
|
+
new Set(have.map((e) => e[k])).size === have.length);
|
|
1054
|
+
if (key) {
|
|
1055
|
+
const byId = new Map(have.map((e) => [e[key], e]));
|
|
1056
|
+
for (const el of want) {
|
|
1057
|
+
const id = el[key];
|
|
1058
|
+
walk(el, byId.get(id), `${at}[${key}=${id}]`);
|
|
1059
|
+
}
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (want.length === have.length) {
|
|
1063
|
+
for (let i = 0; i < want.length; i += 1)
|
|
1064
|
+
walk(want[i], have[i], `${at}[${i}]`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
if (want !== null && typeof want === "object" && !Array.isArray(want) && have !== null && typeof have === "object" && !Array.isArray(have)) {
|
|
1069
|
+
for (const key of Object.keys(want)) {
|
|
1070
|
+
walk(want[key], have[key], `${at}.${key}`);
|
|
1071
|
+
}
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
const changes = diffMetadata(coerceLeaf(canonicalizeCurrencyish(want)), coerceLeaf(canonicalizeCurrencyish(have)), at);
|
|
1075
|
+
if (changes.length === 0)
|
|
1076
|
+
matched += 1;
|
|
1077
|
+
else if (changes.every((c) => c.orderOnly)) {
|
|
1078
|
+
matched += 1;
|
|
1079
|
+
orderOnly += 1;
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
for (const c of changes)
|
|
1083
|
+
mismatches.push({ path: c.path, intended: c.before, observed: c.after });
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
for (const key of Object.keys(intent)) {
|
|
1087
|
+
if (key === "fullName")
|
|
1088
|
+
continue; // path-derived on the wire, unreliable to compare
|
|
1089
|
+
walk(intent[key], observed[key], `metadata.${key}`);
|
|
1090
|
+
}
|
|
1091
|
+
return { matched, mismatches, orderOnly };
|
|
1092
|
+
}
|
|
1093
|
+
async function writeJson(dir, name, value) {
|
|
1094
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1095
|
+
await fs.writeFile(path.join(dir, name), `${JSON.stringify(value, null, 1)}\n`, "utf8");
|
|
1096
|
+
}
|
|
1097
|
+
// Exported for tests: the sf CLI speaks TWO different JSON envelopes and getting this wrong
|
|
1098
|
+
// costs a blind debugging session (see the 6.5.4 note below).
|
|
1099
|
+
export function parseDeployOutput(stdout, stderr) {
|
|
1100
|
+
try {
|
|
1101
|
+
const parsed = JSON.parse(stdout);
|
|
1102
|
+
const cf = parsed.result?.details?.componentFailures;
|
|
1103
|
+
const failures = cf === undefined ? [] : Array.isArray(cf) ? cf : [cf];
|
|
1104
|
+
if (parsed.result?.status) {
|
|
1105
|
+
return { status: parsed.result.status, id: parsed.result.id, failures, raw: parsed.result };
|
|
1106
|
+
}
|
|
1107
|
+
// No result.status — read the error envelope instead of reporting a bare "UNKNOWN".
|
|
1108
|
+
// Live-caught (6.5.4 run): a wave-4 checkOnly returned status UNKNOWN with an EMPTY
|
|
1109
|
+
// failures array and no diagnostic at all, so the operator had to split the wave blind to
|
|
1110
|
+
// find which component was at fault. The CLI's message was there the whole time.
|
|
1111
|
+
const name = parsed.name ?? "UNPARSEABLE_RESULT";
|
|
1112
|
+
const message = parsed.message ?? (stderr.slice(-600) || stdout.slice(-600));
|
|
1113
|
+
return {
|
|
1114
|
+
status: name,
|
|
1115
|
+
failures: failures.length > 0 ? failures : [{ name, message }],
|
|
1116
|
+
raw: parsed
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
catch {
|
|
1120
|
+
return { status: "UNPARSEABLE", failures: [{ stdoutTail: stdout.slice(-800), stderrTail: stderr.slice(-400) }] };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
async function sfDeploy(projectDir, targetOrg, dryRun, timeoutMs,
|
|
1124
|
+
/**
|
|
1125
|
+
* Recover the job after a timeout instead of reporting an unknown outcome.
|
|
1126
|
+
*
|
|
1127
|
+
* OFF for the dry run: a validation mutates nothing, so an abandoned one costs nothing and there
|
|
1128
|
+
* is no ambiguity to resolve — the caller retries it once, which is cheaper than reporting on it.
|
|
1129
|
+
* ON for the real deploy, where the whole point is that the job is still writing.
|
|
1130
|
+
*/
|
|
1131
|
+
recover) {
|
|
1132
|
+
// `--wait` under our own kill, so a slow deploy returns a parseable result naming its job rather
|
|
1133
|
+
// than dying mid-poll. See waitFlagMinutes.
|
|
1134
|
+
const waitMinutes = waitFlagMinutes(timeoutMs);
|
|
1135
|
+
const args = [
|
|
1136
|
+
"project",
|
|
1137
|
+
"deploy",
|
|
1138
|
+
"start",
|
|
1139
|
+
...(dryRun ? ["--dry-run"] : []),
|
|
1140
|
+
"-o",
|
|
1141
|
+
targetOrg,
|
|
1142
|
+
...(waitMinutes ? ["--wait", String(waitMinutes)] : []),
|
|
1143
|
+
"--json"
|
|
1144
|
+
];
|
|
1145
|
+
// Captured BEFORE the spawn: it is what distinguishes this call's cache entry from a concurrent
|
|
1146
|
+
// wave's, and it has to predate anything the CLI could have written.
|
|
1147
|
+
const startedAtMs = Date.now();
|
|
1148
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), cwd: projectDir, timeoutMs });
|
|
1149
|
+
if (!run.timedOut)
|
|
1150
|
+
return parseDeployOutput(run.stdout, run.stderr);
|
|
1151
|
+
if (!recover)
|
|
1152
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1153
|
+
return await recoverTimedOutDeploy(projectDir, targetOrg, startedAtMs, timeoutMs, recover.notes);
|
|
1154
|
+
}
|
|
1155
|
+
/**
|
|
1156
|
+
* Turn a killed watcher into a determinate answer, or say honestly that it could not.
|
|
1157
|
+
*
|
|
1158
|
+
* Best-effort at every step, and every failure lands back on the OLD behaviour (`TIMED_OUT` →
|
|
1159
|
+
* `AMBIGUOUS_DELIVERY`), because the thing being recovered from is already the ambiguous case. What
|
|
1160
|
+
* it must never do is manufacture certainty: a job we cannot identify, cannot report on, or cannot
|
|
1161
|
+
* stop leaves the wave exactly as ambiguous as it was, with a note saying which.
|
|
1162
|
+
*/
|
|
1163
|
+
async function recoverTimedOutDeploy(projectDir, targetOrg, startedAtMs, timeoutMs, notes) {
|
|
1164
|
+
const jobId = selectOwnJobId(await readDeployCache(), targetOrg, startedAtMs);
|
|
1165
|
+
if (!jobId) {
|
|
1166
|
+
notes.push("deploy killed at timeout and the job id could NOT be recovered from the sf deploy cache — the Salesforce job may still be running and still writing. The post-projection below is the authority; nothing here was cancelled.");
|
|
1167
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1168
|
+
}
|
|
1169
|
+
// Bounded: this runs AFTER we already spent the full deploy timeout, so it gets a fraction rather
|
|
1170
|
+
// than another full budget. A report that cannot answer inside it is the same answer as no report.
|
|
1171
|
+
const reportTimeout = Math.max(30_000, Math.floor(timeoutMs / 4));
|
|
1172
|
+
const reported = await sfDeployReport(projectDir, targetOrg, jobId, reportTimeout);
|
|
1173
|
+
if (!reported.timedOut && !deployStillRunning(reported) && reported.status !== "UNPARSEABLE") {
|
|
1174
|
+
notes.push(`deploy watcher was killed at timeout, but the Salesforce job (${jobId}) was recovered from the sf deploy cache and reported ${reported.status} — this verdict is the ORG's answer, not a guess. The kill destroyed our watcher, never the job.`);
|
|
1175
|
+
return reported;
|
|
1176
|
+
}
|
|
1177
|
+
// Still running (or unanswerable). This is the case where the mutation window below is knowably
|
|
1178
|
+
// wrong: it will stamp itself shut while the job keeps writing. Stop the job so it cannot.
|
|
1179
|
+
if (deployStillRunning(reported)) {
|
|
1180
|
+
const cancelled = await sfDeployCancel(projectDir, targetOrg, jobId, reportTimeout);
|
|
1181
|
+
notes.push(cancelled.status === "Canceled" || cancelled.status === "Succeeded"
|
|
1182
|
+
? `deploy killed at timeout; the Salesforce job (${jobId}) was still running and has been CANCELLED, so it cannot keep writing after this call's mutation window closes. Salesforce rolls a cancelled deploy back (rollbackOnError), but the wave is AMBIGUOUS until the read-back below says otherwise.`
|
|
1183
|
+
: `deploy killed at timeout; the Salesforce job (${jobId}) was still running and the cancel did NOT succeed (${cancelled.status}) — it may have reached Finalizing, in which case it is landing now. The job may still be writing AFTER this call's mutation window closes, so the change scan below cannot see everything it does.`);
|
|
1184
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1185
|
+
}
|
|
1186
|
+
notes.push(`deploy killed at timeout; the Salesforce job (${jobId}) was found but could not be reported on (${reported.status}). Its outcome is unknown here — the post-projection below is the authority.`);
|
|
1187
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1188
|
+
}
|
|
1189
|
+
export function parseDeployCache(raw) {
|
|
1190
|
+
try {
|
|
1191
|
+
const parsed = JSON.parse(raw);
|
|
1192
|
+
if (!parsed || typeof parsed !== "object")
|
|
1193
|
+
return [];
|
|
1194
|
+
return Object.entries(parsed)
|
|
1195
|
+
.filter(([jobId]) => /^0Af[A-Za-z0-9]{12,15}$/.test(jobId))
|
|
1196
|
+
.map(([jobId, v]) => ({ jobId, targetOrg: v?.["target-org"], timestamp: v?.timestamp }));
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
return [];
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* The job this call started, and NOT a concurrent wave's.
|
|
1204
|
+
*
|
|
1205
|
+
* The cache is global to `~/.sf` — not per-project and not per-org — which is exactly why
|
|
1206
|
+
* `--use-most-recent` is banned everywhere in this file (survey §4): with two waves in flight it
|
|
1207
|
+
* resolves to whichever finished writing last, and reporting on the wrong job is worse than
|
|
1208
|
+
* reporting on none. So the caller passes the instant the deploy STARTED, and only an entry for this
|
|
1209
|
+
* org stamped at or after that instant can be ours.
|
|
1210
|
+
*
|
|
1211
|
+
* Still not a proof of identity — two waves against the SAME org overlapping within one second would
|
|
1212
|
+
* be indistinguishable — so the caller treats a recovered result as evidence, and any ambiguity
|
|
1213
|
+
* keeps the ambiguous verdict.
|
|
1214
|
+
*/
|
|
1215
|
+
export function selectOwnJobId(entries, targetOrg, startedAtMs) {
|
|
1216
|
+
const mine = entries
|
|
1217
|
+
.filter((e) => e.targetOrg === targetOrg && e.timestamp)
|
|
1218
|
+
.map((e) => ({ ...e, at: Date.parse(e.timestamp) }))
|
|
1219
|
+
.filter((e) => Number.isFinite(e.at) && e.at >= startedAtMs - 1000)
|
|
1220
|
+
.sort((a, b) => b.at - a.at);
|
|
1221
|
+
return mine[0]?.jobId ?? null;
|
|
1222
|
+
}
|
|
1223
|
+
async function readDeployCache() {
|
|
1224
|
+
try {
|
|
1225
|
+
return parseDeployCache(await fs.readFile(path.join(os.homedir(), ".sf", "deploy-cache.json"), "utf8"));
|
|
1226
|
+
}
|
|
1227
|
+
catch {
|
|
1228
|
+
return [];
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* Ask the org what actually happened to a deploy we stopped watching.
|
|
1233
|
+
*
|
|
1234
|
+
* `report --job-id` reaches the org; it is not a cache read. Two ordering constraints, both
|
|
1235
|
+
* load-bearing:
|
|
1236
|
+
*
|
|
1237
|
+
* - It must run BEFORE the `finally` that `fs.rm`s `projectDir`. When the cache entry is missing,
|
|
1238
|
+
* `report` falls back to `SfProject.resolve()` to build a ComponentSet for nicer output
|
|
1239
|
+
* (`report.js:88-96`), and a deleted temp project turns that into a confusing failure.
|
|
1240
|
+
* - `--job-id` ALWAYS, `--use-most-recent` NEVER. See selectOwnJobId.
|
|
1241
|
+
*
|
|
1242
|
+
* `--wait` makes it poll; without the flag it checks once and returns (help text, confirmed at
|
|
1243
|
+
* `report.js:127`). We poll, because the whole point is to let a slow deploy finish.
|
|
1244
|
+
*/
|
|
1245
|
+
async function sfDeployReport(cwd, targetOrg, jobId, timeoutMs) {
|
|
1246
|
+
const args = ["project", "deploy", "report", "--job-id", jobId, "--target-org", targetOrg, "--json"];
|
|
1247
|
+
const waitMinutes = waitFlagMinutes(timeoutMs);
|
|
1248
|
+
if (waitMinutes)
|
|
1249
|
+
args.push("--wait", String(waitMinutes));
|
|
1250
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), cwd, timeoutMs });
|
|
1251
|
+
if (run.timedOut)
|
|
1252
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1253
|
+
return parseDeployOutput(run.stdout, run.stderr);
|
|
1254
|
+
}
|
|
1255
|
+
/**
|
|
1256
|
+
* Write the edits into a temp SFDX project — the bytes Salesforce will actually be handed.
|
|
1257
|
+
*
|
|
1258
|
+
* EXTRACTED SO THERE IS EXACTLY ONE OF THESE. `validate_components` submits the analyzer's own
|
|
1259
|
+
* files as a checkOnly, and the whole value of that call is that a clean result means the same
|
|
1260
|
+
* thing the supervisor's later checkOnly will mean. Two copies of this staging would break that
|
|
1261
|
+
* quietly: the same authored file could stage differently in the two paths — a container child
|
|
1262
|
+
* grouped one way here and another way there, a namespace added on one side only — and the
|
|
1263
|
+
* analyzer's green would stop predicting the wave's green while still looking like it did.
|
|
1264
|
+
*
|
|
1265
|
+
* The three shapes it has to get right are all here for that reason: content-file types write a
|
|
1266
|
+
* meta and a body, decomposed children accumulate into ONE parent container file per path, and
|
|
1267
|
+
* every XML root gets its namespaces bound (`ensureDeployableXml`).
|
|
1268
|
+
*
|
|
1269
|
+
* The caller owns the directory and must remove it.
|
|
1270
|
+
*/
|
|
1271
|
+
export async function stageDeployProject(edits, apiVersion) {
|
|
1272
|
+
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), "cc-mcp-direct-"));
|
|
1273
|
+
await fs.writeFile(path.join(projectDir, "sfdx-project.json"), JSON.stringify({ packageDirectories: [{ path: "force-app", default: true }], sourceApiVersion: apiVersion }), "utf8");
|
|
1274
|
+
// Decomposed children accumulate into their parent container file, keyed by path.
|
|
1275
|
+
const containers = new Map();
|
|
1276
|
+
for (const edit of edits) {
|
|
1277
|
+
const resolved = resolveSourcePath(edit);
|
|
1278
|
+
if ("error" in resolved)
|
|
1279
|
+
continue; // unreachable in runDirectApply: filtered above
|
|
1280
|
+
if ("container" in resolved) {
|
|
1281
|
+
const { containerPath, parentRoot, childElementName } = resolved.container;
|
|
1282
|
+
const group = containers.get(containerPath) ?? { parentRoot, elements: [] };
|
|
1283
|
+
group.elements.push(wrapChildElement(edit.xml, edit.typeName, childElementName));
|
|
1284
|
+
containers.set(containerPath, group);
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
for (const file of resolved.files) {
|
|
1288
|
+
const abs = path.join(projectDir, "force-app", "main", "default", file.relPath);
|
|
1289
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
1290
|
+
if (file.from === "xml") {
|
|
1291
|
+
await fs.writeFile(abs, ensureDeployableXml(edit.xml), "utf8");
|
|
1292
|
+
}
|
|
1293
|
+
else {
|
|
1294
|
+
// content file: written raw (utf8), or base64-decoded for binary static resources.
|
|
1295
|
+
const buf = edit.contentEncoding === "base64" ? Buffer.from(edit.content ?? "", "base64") : edit.content ?? "";
|
|
1296
|
+
await fs.writeFile(abs, buf);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
// Emit one parent container file per group (all same-parent children together).
|
|
1301
|
+
for (const [relPath, { parentRoot, elements }] of containers) {
|
|
1302
|
+
const abs = path.join(projectDir, "force-app", "main", "default", relPath);
|
|
1303
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
1304
|
+
const doc = `<?xml version="1.0" encoding="UTF-8"?>\n<${parentRoot} ${XML_NS}>\n ${elements.join("\n ")}\n</${parentRoot}>\n`;
|
|
1305
|
+
await fs.writeFile(abs, doc, "utf8");
|
|
1306
|
+
}
|
|
1307
|
+
return projectDir;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Submit components to the org as a checkOnly deploy and report what it said. NOTHING ELSE.
|
|
1311
|
+
*
|
|
1312
|
+
* This is the apply loop's step 4 — the authoritative rejection tier — with every stage around it
|
|
1313
|
+
* removed. No pre-projection, no post-projection, no diff, no evidence bundle, no mirror commit, no
|
|
1314
|
+
* verdict. It exists so an ANALYZER can ask the org "would you accept this?" about the components
|
|
1315
|
+
* its own plan.json owns, in parallel with fourteen other analyzers, during Ch.4 authoring rather
|
|
1316
|
+
* than at Ch.7 deploy.
|
|
1317
|
+
*
|
|
1318
|
+
* IT MUTATES NOTHING, which is why it needs no write gate. `--dry-run` is a deploy-API option, and
|
|
1319
|
+
* the measurement backing that claim is in `.audit/parallel-validation.md`: 26 concurrent validation
|
|
1320
|
+
* deploys against a live org, 8 of them refused, left zero rows behind in `FieldDefinition`.
|
|
1321
|
+
*
|
|
1322
|
+
* FAILURES ARE VERBATIM. The whole point of moving refusal discovery earlier is that the analyzer
|
|
1323
|
+
* gets Salesforce's own sentence — "length must be specified", "Cannot delete last filter" — while
|
|
1324
|
+
* it is still holding the context that produced the file. Paraphrasing here would destroy the value
|
|
1325
|
+
* of the call, so `problem` is passed through untouched.
|
|
1326
|
+
*/
|
|
1327
|
+
export async function validateComponents(args) {
|
|
1328
|
+
const startedAt = Date.now();
|
|
1329
|
+
const projectDir = await stageDeployProject(args.edits, args.apiVersion);
|
|
1330
|
+
try {
|
|
1331
|
+
const outcome = await sfDeploy(projectDir, args.targetOrg, true, args.timeoutMs ?? 300_000);
|
|
1332
|
+
const failures = (Array.isArray(outcome.failures) ? outcome.failures : [])
|
|
1333
|
+
.filter((f) => typeof f === "object" && f !== null)
|
|
1334
|
+
.map((f) => ({
|
|
1335
|
+
// A CLI ERROR envelope has no component at all (parseDeployOutput synthesizes
|
|
1336
|
+
// `{name, message}`), and reporting that as a component named "undefined" would read as a
|
|
1337
|
+
// refusal of something rather than as the org never answering about anything.
|
|
1338
|
+
component: typeof f.fullName === "string" ? f.fullName : typeof f.name === "string" ? f.name : "(whole deploy)",
|
|
1339
|
+
problem: typeof f.problem === "string" ? f.problem : typeof f.message === "string" ? f.message : JSON.stringify(f),
|
|
1340
|
+
...(typeof f.problemType === "string" ? { problemType: f.problemType } : {}),
|
|
1341
|
+
...(typeof f.lineNumber === "number" ? { line: f.lineNumber } : {}),
|
|
1342
|
+
...(typeof f.columnNumber === "number" ? { column: f.columnNumber } : {})
|
|
1343
|
+
}));
|
|
1344
|
+
return {
|
|
1345
|
+
status: outcome.status,
|
|
1346
|
+
// A TIMED_OUT run is NOT accepted and NOT refused — the org never answered. Reading it as
|
|
1347
|
+
// either would be the "a check that was ATTEMPTED is not a check that ANSWERED" failure this
|
|
1348
|
+
// codebase already refuses everywhere else.
|
|
1349
|
+
accepted: outcome.status === "Succeeded",
|
|
1350
|
+
failures,
|
|
1351
|
+
timedOut: outcome.timedOut === true,
|
|
1352
|
+
componentCount: args.edits.length,
|
|
1353
|
+
durationMs: Date.now() - startedAt,
|
|
1354
|
+
raw: outcome.raw ?? null
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
finally {
|
|
1358
|
+
await fs.rm(projectDir, { recursive: true, force: true });
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Stop a deploy nobody is watching any more.
|
|
1363
|
+
*
|
|
1364
|
+
* Today an abandoned job keeps writing to the operator's org while `mutation-window.json` stamps
|
|
1365
|
+
* itself shut — so the change scan asks "what moved between these timestamps" over a window that
|
|
1366
|
+
* closed before the writing did, and a component the abandoned deploy lands afterwards is invisible
|
|
1367
|
+
* to it. That is the one case where our window is knowably wrong.
|
|
1368
|
+
*
|
|
1369
|
+
* ONLY after `report` says the job is still running. Cancelling is itself a deploy operation with
|
|
1370
|
+
* its own id and its own poll (`deploy.js:cancelDeploy`), and it fails once the deploy reaches
|
|
1371
|
+
* Finalizing — a failed cancel leaves the wave ambiguous, which is what the caller already says.
|
|
1372
|
+
*/
|
|
1373
|
+
async function sfDeployCancel(cwd, targetOrg, jobId, timeoutMs) {
|
|
1374
|
+
const args = ["project", "deploy", "cancel", "--job-id", jobId, "--target-org", targetOrg, "--json"];
|
|
1375
|
+
const waitMinutes = waitFlagMinutes(timeoutMs);
|
|
1376
|
+
if (waitMinutes)
|
|
1377
|
+
args.push("--wait", String(waitMinutes));
|
|
1378
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), cwd, timeoutMs });
|
|
1379
|
+
if (run.timedOut)
|
|
1380
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1381
|
+
return parseDeployOutput(run.stdout, run.stderr);
|
|
1382
|
+
}
|
|
1383
|
+
/** Is this job still running — i.e. is there anything to cancel? Never cancel a finished deploy. */
|
|
1384
|
+
export function deployStillRunning(outcome) {
|
|
1385
|
+
return /^(Pending|InProgress|Canceling)$/i.test(outcome.status);
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* May this wave skip its checkOnly and submit once?
|
|
1389
|
+
*
|
|
1390
|
+
* THE CORPUS MAKES THE CASE. Across 121 recorded applies: 13 waves had a failing dry run, which
|
|
1391
|
+
* correctly stopped them, and ZERO had a real deploy fail after a clean one. Every real deploy ran
|
|
1392
|
+
* with `rollbackOnError: true` (confirmed in all 86 `deploy.json` raws), so a rejected deploy is
|
|
1393
|
+
* already atomic — nothing partial lands. On that evidence the checkOnly is buying a nicer error
|
|
1394
|
+
* SURFACE, not safety, at a measured median 9.3s / p90 19.3s per wave.
|
|
1395
|
+
*
|
|
1396
|
+
* WHAT THE CORPUS DOES NOT SAY is what happens on the runs nobody has made yet, and "13 waves were
|
|
1397
|
+
* stopped by the dry run" is the number that argues for keeping it: those 13 got their failure from
|
|
1398
|
+
* a submission that could not have written anything, which is a materially calmer thing to hand a
|
|
1399
|
+
* non-technical operator than a real deploy's rejection. So this is OFF unless an env var turns it
|
|
1400
|
+
* on, and the note it pushes says exactly what was given up.
|
|
1401
|
+
*
|
|
1402
|
+
* THREE HARD GATES, none of them env-overridable:
|
|
1403
|
+
*
|
|
1404
|
+
* - DELETES keep their checkOnly unconditionally. A destructive checkOnly is not a rehearsal, it
|
|
1405
|
+
* is the only way to get PER-MEMBER outcomes (blocked by a live reference, with the referrer
|
|
1406
|
+
* named) — `destructive.ts` exists for that payload, and a real destructive deploy destroys
|
|
1407
|
+
* things while answering the same question.
|
|
1408
|
+
* - FACET UPDATES keep it. They go through a CRUD read-modify-write with no validation step of
|
|
1409
|
+
* their own, so the file half's dry run is the only pre-flight the wave has.
|
|
1410
|
+
* - `dryRunOnly` keeps it, obviously and structurally: the dry run IS the deliverable there, and a
|
|
1411
|
+
* skip would return VALIDATED having submitted nothing anywhere.
|
|
1412
|
+
*/
|
|
1413
|
+
export function checkOnlyIsSkippable(wave, env) {
|
|
1414
|
+
if (env.CC_MCP_SKIP_CHECKONLY !== "1")
|
|
1415
|
+
return false;
|
|
1416
|
+
if (wave.dryRunOnly)
|
|
1417
|
+
return false;
|
|
1418
|
+
if (wave.edits.length === 0)
|
|
1419
|
+
return false;
|
|
1420
|
+
return wave.deletes.length === 0 && wave.facetUpdates.length === 0;
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* `--wait`, in minutes, aligned JUST UNDER our own SIGKILL — or `null` when there is no room.
|
|
1424
|
+
*
|
|
1425
|
+
* We passed no `--wait` at all, so the CLI used its 33-minute default against our 300s kill: the CLI
|
|
1426
|
+
* was willing to watch 6.6x longer than we let it live, which made OUR kill the binding constraint
|
|
1427
|
+
* and guaranteed the loss was a SIGKILL mid-poll rather than a parseable result naming its job.
|
|
1428
|
+
* Landing a minute under the kill lets the CLI give up first and return that result.
|
|
1429
|
+
*
|
|
1430
|
+
* `min: 1` on the flag (`start.js:139`), and the tests run a 1000ms timeout — so anything that
|
|
1431
|
+
* cannot fit a whole minute under the kill omits the flag entirely rather than asking for a wait
|
|
1432
|
+
* longer than the process will live.
|
|
1433
|
+
*/
|
|
1434
|
+
export function waitFlagMinutes(timeoutMs) {
|
|
1435
|
+
const minutes = Math.floor(timeoutMs / 60_000) - 1;
|
|
1436
|
+
return minutes >= 1 ? minutes : null;
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Submit a destructiveChanges.xml package — as a practice run (`dryRun`) or for real.
|
|
1440
|
+
*
|
|
1441
|
+
* `--metadata-dir` takes `--dry-run` just as a source deploy does, which is what makes a delete
|
|
1442
|
+
* checkable at all. Both modes go through here so the two cannot drift apart: measured on T-Pinto,
|
|
1443
|
+
* the identical package with and without `--dry-run` returned byte-identical failure payloads, and
|
|
1444
|
+
* that equivalence is only worth anything while the same package builder feeds both.
|
|
1445
|
+
*
|
|
1446
|
+
* `--ignore-warnings` is NEVER passed, and this is load-bearing rather than an omission. A
|
|
1447
|
+
* destructive member that does not exist in the org is reported as a WARNING, and with warnings
|
|
1448
|
+
* ignored the org returns `Succeeded` with the missing member erased from the response entirely —
|
|
1449
|
+
* no failure, no warning, nothing. Default flags are what make a typo'd member name fail loudly.
|
|
1450
|
+
*/
|
|
1451
|
+
async function sfDestructiveDeploy(deletes, targetOrg, apiVersion, dryRun, timeoutMs, cwd) {
|
|
1452
|
+
const mdapiDir = await fs.mkdtemp(path.join(os.tmpdir(), "cc-mcp-direct-del-"));
|
|
1453
|
+
try {
|
|
1454
|
+
await fs.writeFile(path.join(mdapiDir, "destructiveChanges.xml"), destructivePackageXml(deletes), "utf8");
|
|
1455
|
+
await fs.writeFile(path.join(mdapiDir, "package.xml"), emptyPackageXml(apiVersion), "utf8");
|
|
1456
|
+
const args = [
|
|
1457
|
+
"project",
|
|
1458
|
+
"deploy",
|
|
1459
|
+
"start",
|
|
1460
|
+
"--metadata-dir",
|
|
1461
|
+
mdapiDir,
|
|
1462
|
+
...(dryRun ? ["--dry-run"] : []),
|
|
1463
|
+
"-o",
|
|
1464
|
+
targetOrg,
|
|
1465
|
+
"--json"
|
|
1466
|
+
];
|
|
1467
|
+
const run = await runProcess("sf", args, { env: sfCliEnv(), cwd, timeoutMs });
|
|
1468
|
+
if (run.timedOut)
|
|
1469
|
+
return { status: "TIMED_OUT", failures: [], timedOut: true };
|
|
1470
|
+
return parseDeployOutput(run.stdout, run.stderr);
|
|
1471
|
+
}
|
|
1472
|
+
finally {
|
|
1473
|
+
await fs.rm(mdapiDir, { recursive: true, force: true });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// A read-back failure AFTER the mutations landed (deploy/deletes/facets already ran) must
|
|
1477
|
+
// NOT discard them. This is the same contract as a deploy timeout — AMBIGUOUS_DELIVERY, not
|
|
1478
|
+
// an INTERNAL_ERROR that throws away a successful deploy. Live-caught (6.5.1 run, 2026-07-24):
|
|
1479
|
+
// a `fetch failed` on the post-projection read crashed a QuickAction deploy that had in fact
|
|
1480
|
+
// LANDED (id 0Aff…). The operator re-projects to confirm rather than blind-re-applying.
|
|
1481
|
+
export function ambiguousReadbackResult(args) {
|
|
1482
|
+
const msg = args.error instanceof Error ? args.error.message : String(args.error);
|
|
1483
|
+
return {
|
|
1484
|
+
verdict: "AMBIGUOUS_DELIVERY",
|
|
1485
|
+
bundleDir: args.bundleDir,
|
|
1486
|
+
lint: args.lint,
|
|
1487
|
+
...(args.deploy ? { deploy: args.deploy } : {}),
|
|
1488
|
+
...(args.deletes?.length ? { deletes: args.deletes } : {}),
|
|
1489
|
+
...(args.facetUpdates?.length ? { facetUpdates: args.facetUpdates } : {}),
|
|
1490
|
+
notes: [
|
|
1491
|
+
...args.priorNotes,
|
|
1492
|
+
`post-deploy read-back FAILED (${msg}) — the deploy/mutations above may have LANDED; verdict is AMBIGUOUS_DELIVERY, not a failure. Re-project the scope (project_org) to confirm final state before any re-apply — a blind re-create of a landed component hits the anti-tamper refusal.`
|
|
1493
|
+
]
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* Materialize the wave into its org's mirror, then finish the result and persist it.
|
|
1498
|
+
*
|
|
1499
|
+
* A POST-VERDICT SIDE EFFECT in the strict sense: the verdict is already decided and already on
|
|
1500
|
+
* disk before this runs, and nothing here can change it. Three independent things keep it that way,
|
|
1501
|
+
* and the redundancy is deliberate because the failure is silent in every direction:
|
|
1502
|
+
*
|
|
1503
|
+
* 1. It is only ever called from the two paths that reach a verdict AFTER the deploy.
|
|
1504
|
+
* 2. `dryRunOnly` returns immediately — a practice run mutated nothing, so there is nothing to
|
|
1505
|
+
* record. (Step 4c already returns before this point; this is the second lock on that door,
|
|
1506
|
+
* in the spirit of the structural guard the dryRunOnly fix installed there.)
|
|
1507
|
+
* 3. `readWaveFromBundle` yields nothing for a verdict that did not change the org, so
|
|
1508
|
+
* LINT_BLOCKED / REJECTED_BY_ORG / VALIDATED cannot commit even if a future caller forgets.
|
|
1509
|
+
*
|
|
1510
|
+
* And it reads the wave back OUT OF THE BUNDLE rather than from the variables in scope, which is
|
|
1511
|
+
* why verdict.json is written more than once. A rebuild on another machine has only the bundle; if
|
|
1512
|
+
* the live commit were assembled from richer in-memory state, the two would materialize differently
|
|
1513
|
+
* and the divergence would surface as mismatched SHAs long after the wave that caused it. Same
|
|
1514
|
+
* reader, same commit, by construction.
|
|
1515
|
+
*
|
|
1516
|
+
* The wave's provenance parcel (U6) rides the same seam and goes LAST, so what ships is the finished
|
|
1517
|
+
* bundle rather than one revision of it.
|
|
1518
|
+
*/
|
|
1519
|
+
async function recordInMirror(result, opts) {
|
|
1520
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
1521
|
+
// The wave, once, for both side effects. Null keeps the parcel step from running for a verdict
|
|
1522
|
+
// that changed nothing or a bundle with no org id — the same gate the mirror commit sits behind.
|
|
1523
|
+
let parcelFor = null;
|
|
1524
|
+
if (!opts.dryRunOnly && isStateChanging(result.verdict)) {
|
|
1525
|
+
let read = null;
|
|
1526
|
+
try {
|
|
1527
|
+
read = await readWaveFromBundle(opts.bundleDir);
|
|
1528
|
+
if (!read) {
|
|
1529
|
+
result.notes.push("org history NOT recorded for this wave — the projection carries no org id to key the mirror by. The delivery is unaffected; the evidence bundle is intact and a later rebuild will pick this wave up once the id is present. No provenance parcel was queued for it either, for the same reason.");
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
const outcome = await overlayAndCommit({ wave: read.wave, preComponents: read.preComponents });
|
|
1533
|
+
result.mirror = {
|
|
1534
|
+
committed: outcome.committed,
|
|
1535
|
+
...(outcome.sha ? { sha: outcome.sha } : {}),
|
|
1536
|
+
...(outcome.drifted.length ? { drifted: outcome.drifted } : {}),
|
|
1537
|
+
...(outcome.attributed.length ? { attributed: outcome.attributed } : {}),
|
|
1538
|
+
...(outcome.incomparable.length ? { incomparable: outcome.incomparable } : {})
|
|
1539
|
+
};
|
|
1540
|
+
for (const entry of outcome.incomparable) {
|
|
1541
|
+
// NOT a drift card and NOT a clean bill. The mirror's copy and this wave's pre-image came
|
|
1542
|
+
// out of different readers, so the difference between them is a fact about OUR code, not
|
|
1543
|
+
// about the org — and an accusation built on it would be false. Reported in full, with
|
|
1544
|
+
// the facets that actually differ, because the one thing this must never become is a
|
|
1545
|
+
// silent skip: a genuine outside edit in this window is invisible to the comparison, and
|
|
1546
|
+
// saying so is the only honest position available.
|
|
1547
|
+
const cause = entry.reason === "READER_CHANGED"
|
|
1548
|
+
? `this machine recorded it with the "${entry.recorded}" reader and read it this time with "${entry.observed}"`
|
|
1549
|
+
: `the reader reported it could not read ${(entry.facets ?? []).join(", ")} on this pass`;
|
|
1550
|
+
result.notes.push(`NOT COMPARABLE, so NOT evidence either way — ${entry.key}. ` +
|
|
1551
|
+
`Its recorded shape could not be checked against this wave's pre-image: ${cause}. ` +
|
|
1552
|
+
`The facet(s) that differ: ${entry.differingKeys.join(", ") || "none outside the reader's own fields"}. ` +
|
|
1553
|
+
`An outside edit to this component before the wave would be indistinguishable from the reader change here, so no claim is made in either direction — this is NOT a clean result. ` +
|
|
1554
|
+
`It self-corrects: this wave records the component under the current reader, so the next comparison is a real one.`);
|
|
1555
|
+
}
|
|
1556
|
+
for (const entry of outcome.attributed) {
|
|
1557
|
+
// NOT a drift card. The mirror's copy of the container was stale because THIS install
|
|
1558
|
+
// delivered its children — children ride inside a CustomObject's read-back but are
|
|
1559
|
+
// recorded as their own components, so the parent's file was never refreshed. Reported
|
|
1560
|
+
// because a silent correction is still a correction, and the operator should be able to
|
|
1561
|
+
// see that the system knew the difference.
|
|
1562
|
+
result.notes.push(`STALE PARENT IN THIS MACHINE'S OWN RECORD, not an outside change — ${entry.key}. ` +
|
|
1563
|
+
`Its recorded shape predates this install's own delivery of ${entry.children.join(", ")} ` +
|
|
1564
|
+
`(${entry.waves.join(", ")}), which ride inside the object's read-back but are recorded separately. ` +
|
|
1565
|
+
`Every difference was accounted for by those deliveries; one that was not would have been carded. ` +
|
|
1566
|
+
`Nothing to ask the operator. Edits to those children themselves are checked against their own records, not this one.`);
|
|
1567
|
+
}
|
|
1568
|
+
if (outcome.drifted.length > 0) {
|
|
1569
|
+
result.notes.push(`CHANGED OUTSIDE THIS MACHINE'S HISTORY — ${outcome.drifted.join(", ")}. ` +
|
|
1570
|
+
`Before this wave ran, ${outcome.drifted.length === 1 ? "that component did" : "those components did"} not match what this machine last recorded after IN2 touched ${outcome.drifted.length === 1 ? "it" : "them"}. ` +
|
|
1571
|
+
`A client admin editing in Setup and another machine's wave that has not synced here look IDENTICAL from this side — this install now ships its own parcels, but nothing fetches another machine's yet, so this history still covers only what THIS install delivered, and the claim stops there. ` +
|
|
1572
|
+
`A warning, never a refusal: the wave proceeded. Check that it did not overwrite an edit someone else meant to keep.`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
catch (error) {
|
|
1577
|
+
// R4: a mirror that cannot write must never fail, delay or mask a delivery that landed.
|
|
1578
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1579
|
+
result.mirror = { committed: false, error: message };
|
|
1580
|
+
result.notes.push(`org history NOT recorded for this wave (${message}) — the delivery itself is unaffected and its evidence bundle is complete. The mirror is a rebuildable cache: this wave rejoins history on the next rebuild.`);
|
|
1581
|
+
}
|
|
1582
|
+
parcelFor = read;
|
|
1583
|
+
}
|
|
1584
|
+
result.operatorSummary = operatorSummaryFor(result);
|
|
1585
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
1586
|
+
// The provenance parcel (R7), built LAST and from the bundle on disk.
|
|
1587
|
+
//
|
|
1588
|
+
// Ordering is the point. The parcel tars the directory, so anything written after it would ship a
|
|
1589
|
+
// bundle that is a revision behind the one a rebuild reads — the mirror outcome, the drift note
|
|
1590
|
+
// and the operator summary all land in the write immediately above. The only thing the shipped
|
|
1591
|
+
// copy cannot carry is `result.parcel` itself, which is a statement about the parcel and could
|
|
1592
|
+
// not be inside it; that is what the third write below records locally.
|
|
1593
|
+
//
|
|
1594
|
+
// Deliberately OUTSIDE the mirror's try as well: a mirror that could not commit says nothing
|
|
1595
|
+
// about whether this bundle can ship, and folding the two would let a git failure report itself
|
|
1596
|
+
// as a parcel failure. The network flush is fire-and-forget and runs again before the process
|
|
1597
|
+
// exits, so nothing here can delay a delivery that already landed.
|
|
1598
|
+
if (parcelFor) {
|
|
1599
|
+
// The try is redundant with `queueWaveParcel`'s own no-throw contract, and kept for the reason
|
|
1600
|
+
// the mirror keeps its three locks: the org has already been changed by the time this line
|
|
1601
|
+
// runs, so "the callee promises not to throw" is not good enough to bet a landed delivery on.
|
|
1602
|
+
try {
|
|
1603
|
+
const queued = await queueWaveParcel({
|
|
1604
|
+
bundleDir: opts.bundleDir,
|
|
1605
|
+
orgId: parcelFor.wave.orgId,
|
|
1606
|
+
orgAlias: opts.targetOrg,
|
|
1607
|
+
verdict: result.verdict,
|
|
1608
|
+
attempt: parcelFor.wave.attempt,
|
|
1609
|
+
...(parcelFor.wave.batchId ? { batchId: parcelFor.wave.batchId } : {})
|
|
1610
|
+
});
|
|
1611
|
+
result.parcel = {
|
|
1612
|
+
queued: queued.queued,
|
|
1613
|
+
...(queued.bytes !== undefined ? { bytes: queued.bytes } : {}),
|
|
1614
|
+
...(queued.suppressed ? { suppressed: true } : {}),
|
|
1615
|
+
...(queued.reason ? { error: queued.reason } : {})
|
|
1616
|
+
};
|
|
1617
|
+
if (queued.queued)
|
|
1618
|
+
void flushParcels().catch(() => undefined);
|
|
1619
|
+
}
|
|
1620
|
+
catch (error) {
|
|
1621
|
+
result.parcel = { queued: false, error: error instanceof Error ? error.message : String(error) };
|
|
1622
|
+
}
|
|
1623
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
1624
|
+
}
|
|
1625
|
+
return result;
|
|
1626
|
+
}
|
|
1627
|
+
export async function runDirectApply(opts) {
|
|
1628
|
+
const notes = [];
|
|
1629
|
+
// The org-call meter measures ONE WAVE, so it is cleared at the wave's entry rather than at
|
|
1630
|
+
// process start. One server serves one session's waves serially, which is what makes a
|
|
1631
|
+
// process-global meter a per-wave reading — see core/orgCallMeter.ts.
|
|
1632
|
+
resetOrgCallMeter();
|
|
1633
|
+
const deployTimeout = opts.deployTimeoutMs ?? 300_000;
|
|
1634
|
+
const editKeys = new Set(opts.edits.map((e) => `${e.typeName}:${e.fullName}`));
|
|
1635
|
+
const deleteKeys = new Set(opts.deletes.map((d) => `${d.typeName}:${d.fullName}`));
|
|
1636
|
+
for (const fu of opts.facetUpdates) {
|
|
1637
|
+
const banned = Object.keys(fu.facets).filter((k) => ["fields", "listViews", "compactLayouts", "validationRules", "recordTypes", "fullName"].includes(k));
|
|
1638
|
+
if (banned.length)
|
|
1639
|
+
throw new Error(`objectFacetUpdates carries child collections (${banned.join(", ")}) — use components/deletes for children.`);
|
|
1640
|
+
}
|
|
1641
|
+
const refusals = [];
|
|
1642
|
+
for (const item of opts.edits) {
|
|
1643
|
+
const r = resolveSourcePath(item);
|
|
1644
|
+
if ("error" in r)
|
|
1645
|
+
refusals.push(r.error);
|
|
1646
|
+
}
|
|
1647
|
+
for (const del of opts.deletes) {
|
|
1648
|
+
// deletes carry no xml/content; resolve on typeName/fullName alone.
|
|
1649
|
+
const r = resolveSourcePath({ typeName: del.typeName, fullName: del.fullName, xml: "" });
|
|
1650
|
+
if ("error" in r && !/content-file type/.test(r.error))
|
|
1651
|
+
refusals.push(r.error);
|
|
1652
|
+
}
|
|
1653
|
+
if (refusals.length > 0) {
|
|
1654
|
+
throw new Error(`Unsupported metadata type(s) for the direct tier:\n- ${[...new Set(refusals)].join("\n- ")}`);
|
|
1655
|
+
}
|
|
1656
|
+
// Per-key revision facts, matched to the edits by component key. Built once — the drop guard, the
|
|
1657
|
+
// static preview and `request.json` all read the same map, so they cannot disagree about which
|
|
1658
|
+
// state of a component this wave is deploying.
|
|
1659
|
+
const revisionByKey = new Map((opts.revisions ?? []).map((r) => [`${r.typeName}:${r.fullName}`, r]));
|
|
1660
|
+
// INTENT PER EDIT, and additive on purpose. `request.json` is the only artifact in the bundle that
|
|
1661
|
+
// states what a wave MEANT to do, and until now it could not distinguish "bring this component into
|
|
1662
|
+
// being" from "change what is already there" — the Campfire writeback had to infer ownership from
|
|
1663
|
+
// which requirement got there first (`campfire/deriveFromBundle.ts`), and the mirror had no way to
|
|
1664
|
+
// read it at all.
|
|
1665
|
+
//
|
|
1666
|
+
// Written as one more field on each edit rather than as a parallel structure, so every existing
|
|
1667
|
+
// reader keeps working untouched: `orgRepo.touchedKeys`, `waveGrade.readRequest`,
|
|
1668
|
+
// `batchReport` and `deriveFromBundle` all pick named fields off these objects and ignore the rest.
|
|
1669
|
+
// Bundles written before today carry no `intent`, and every one of them still parses.
|
|
1670
|
+
await writeJson(opts.bundleDir, "request.json", {
|
|
1671
|
+
targetOrg: opts.targetOrg,
|
|
1672
|
+
apiVersion: opts.apiVersion,
|
|
1673
|
+
edits: opts.edits.map((edit) => {
|
|
1674
|
+
const revision = revisionByKey.get(`${edit.typeName}:${edit.fullName}`);
|
|
1675
|
+
return revision?.intent ? { ...edit, intent: revision.intent } : edit;
|
|
1676
|
+
}),
|
|
1677
|
+
...(opts.revisions?.length ? { revisions: opts.revisions } : {}),
|
|
1678
|
+
deletes: opts.deletes,
|
|
1679
|
+
facetUpdates: opts.facetUpdates,
|
|
1680
|
+
acks: opts.acks,
|
|
1681
|
+
...(opts.ackInline ? { ackInline: true } : {}),
|
|
1682
|
+
...(opts.batchId ? { batchId: opts.batchId } : {}),
|
|
1683
|
+
...(opts.requirementIds?.length ? { requirementIds: opts.requirementIds } : {}),
|
|
1684
|
+
...(opts.operator ? { operator: opts.operator } : {}),
|
|
1685
|
+
...(opts.revertOf ? { revertOf: opts.revertOf } : {}),
|
|
1686
|
+
dryRunOnly: opts.dryRunOnly
|
|
1687
|
+
});
|
|
1688
|
+
// 1. Pre-projection. `scope` is a `let` because the post-projection below closes over the same
|
|
1689
|
+
// binding — pre and post MUST project the identical scope or the three-way diff compares
|
|
1690
|
+
// different worlds.
|
|
1691
|
+
//
|
|
1692
|
+
// NARROW BY DEFAULT (plan -002): exactly the touched components, not their parent objects. The
|
|
1693
|
+
// object expansion read 4.6 components for every one touched and found one side change in 70
|
|
1694
|
+
// bundles.
|
|
1695
|
+
//
|
|
1696
|
+
// The referential lint DOES depend on breadth, and this narrowing broke it before the dependency
|
|
1697
|
+
// was made real. The justification written here originally claimed the session union already read
|
|
1698
|
+
// the session's project_org runs; it did not — it read wave post-images only, which this change
|
|
1699
|
+
// had just shrunk to the touched set. CF63 w4 paid for it: a clean `direct_lint
|
|
1700
|
+
// {projection: "ground"}` followed by REF_UNKNOWN at apply on the same files. Breadth now comes
|
|
1701
|
+
// from `collectSessionProjection` below, which unions org-projection/<org>/* as well, so the
|
|
1702
|
+
// claim above is true rather than assumed.
|
|
1703
|
+
const facetObjects = opts.facetUpdates.map((fu) => fu.objectApiName);
|
|
1704
|
+
let scope = opts.wideObjectScope
|
|
1705
|
+
? deriveScope(opts.edits, opts.deletes)
|
|
1706
|
+
: narrowScope(opts.edits, opts.deletes, facetObjects);
|
|
1707
|
+
if (opts.wideObjectScope) {
|
|
1708
|
+
for (const obj of facetObjects) {
|
|
1709
|
+
scope.objects = [...new Set([...(scope.objects ?? []), obj])].sort();
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
// PRECEDENT IS SKIPPED UNLESS THIS WAVE COULD CONSUME IT, and this is the one caller that gets to
|
|
1713
|
+
// decide that (see RunProjectionOptions.skipPrecedent; `project_org` keeps sampling
|
|
1714
|
+
// unconditionally, which is where the vocabulary actually comes from).
|
|
1715
|
+
//
|
|
1716
|
+
// Measured: the sweep contributed 1,296 of 1,977 pre-projection component reads across the corpus
|
|
1717
|
+
// — 66%, on a median of 3 touched components per wave — and an apply pays for it TWICE because
|
|
1718
|
+
// the post projection re-reads the identical scope. 90 of 115 recorded waves touched no
|
|
1719
|
+
// FlexiPage or CustomTab at all, so 90 waves bought ~24 extra reads on each side of their deploy
|
|
1720
|
+
// to feed a lint tier that keys on the EDIT'S typeName and therefore could not have matched them.
|
|
1721
|
+
//
|
|
1722
|
+
// Keyed on the type alone rather than on whether the XML carries <motif>/<componentName>: the
|
|
1723
|
+
// element scan would be a second implementation of what runLint already walks, and on this corpus
|
|
1724
|
+
// the two gates select the identical 25 waves. Type is the coarser, cheaper, and more honest one —
|
|
1725
|
+
// a FlexiPage wave that grows an org-enforced element later keeps its precedent without anyone
|
|
1726
|
+
// remembering to update a predicate here.
|
|
1727
|
+
//
|
|
1728
|
+
// BOTH SIDES OR NEITHER. The post projection must resolve the SAME scope as the pre — the
|
|
1729
|
+
// three-way diff pairs them by key, so precedent present on one side and absent on the other would
|
|
1730
|
+
// surface all ~24 samples as `diff.removed` and land them in `sideChanges` on every single wave,
|
|
1731
|
+
// turning a saving into a false drift report. It also matters after the call: a wave's post image
|
|
1732
|
+
// is what `collectSessionProjection` grounds later waves against, so dropping precedent from the
|
|
1733
|
+
// post alone would quietly narrow the vocabulary the NEXT wave's lint sees.
|
|
1734
|
+
const needsPrecedent = opts.edits.some((edit) => PRECEDENT_TYPE_NAMES.has(edit.typeName));
|
|
1735
|
+
const skipPrecedent = !needsPrecedent;
|
|
1736
|
+
const preDir = path.join(opts.bundleDir, "pre");
|
|
1737
|
+
// Phased for the meter: a projection is TENS of jsforce round-trips (batched ten-per-call, five
|
|
1738
|
+
// in flight), which is where a wave's org time actually goes — against three sf spawns for the
|
|
1739
|
+
// whole tier. Attributing them is the difference between a measurement and a guess.
|
|
1740
|
+
const projectPre = () => orgCallPhase("preProjection", () => runProjection(opts.reader, {
|
|
1741
|
+
scope,
|
|
1742
|
+
outDir: preDir,
|
|
1743
|
+
orgAlias: opts.targetOrg,
|
|
1744
|
+
apiVersion: opts.apiVersion,
|
|
1745
|
+
label: "pre",
|
|
1746
|
+
skipPrecedent
|
|
1747
|
+
}));
|
|
1748
|
+
let pre;
|
|
1749
|
+
try {
|
|
1750
|
+
pre = await projectPre();
|
|
1751
|
+
}
|
|
1752
|
+
catch (error) {
|
|
1753
|
+
if (!(error instanceof ScopeTooLargeError))
|
|
1754
|
+
throw error;
|
|
1755
|
+
// Only reachable with wideObjectScope: the narrow scope names its components, so it cannot
|
|
1756
|
+
// overflow. Parent-object expansion can (a large standard object resolves to hundreds of
|
|
1757
|
+
// components). Fall back to exactly the touched components — the cap exists to stop accidental
|
|
1758
|
+
// breadth, and a bundle that names its components is not accidental. The refusal happens before
|
|
1759
|
+
// any snapshot writes, so retrying into the same preDir is clean.
|
|
1760
|
+
scope = narrowScope(opts.edits, opts.deletes, facetObjects);
|
|
1761
|
+
pre = await projectPre();
|
|
1762
|
+
notes.push(`pre/post projection narrowed to the ${scope.components?.length ?? 0} touched component(s): the full-object scope resolves to ${error.total} components (cap ${error.limit}). Verification of everything this bundle changes is unaffected; sibling side-change detection on the parent object(s) is off for this bundle.`);
|
|
1763
|
+
}
|
|
1764
|
+
// 1b. Deployable pre-image for every touched existing component (plan 2026-07-29-002, widened by
|
|
1765
|
+
// 2026-08-02-002 U1). The canonical JSON under pre/ is the COMPARISON substrate; this is the
|
|
1766
|
+
// DEPLOYABLE one — the bytes an author edits to carry content forward, and the bytes a rollback
|
|
1767
|
+
// redeploys to put a component back. Fail-open by contract: a failed retrieve costs a note and
|
|
1768
|
+
// never touches the verdict.
|
|
1769
|
+
const preImages = preImageTargets(opts.edits, opts.deletes, facetObjects, pre.manifest);
|
|
1770
|
+
if (preImages.length > 0) {
|
|
1771
|
+
const sourceDir = path.join(preDir, "source");
|
|
1772
|
+
const outcome = await orgCallPhase("preImageRetrieve", () => retrievePreImageSource(preImages, opts.targetOrg, opts.apiVersion, sourceDir));
|
|
1773
|
+
if (!outcome.ok) {
|
|
1774
|
+
notes.push(`pre-image source retrieve failed (${outcome.error}) — fall back to the JSON snapshots under pre/; verification is unaffected, but this wave has NO deployable rollback image`);
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
notes.push(`deployable pre-image source for ${outcome.retrieved} touched component(s) at ${sourceDir} — when carrying existing content forward, edit THAT file rather than transcribing the JSON snapshot; it is also what a rollback would redeploy`);
|
|
1778
|
+
// A partial retrieve is not a failure of the wave, but "there is a rollback image" is now true
|
|
1779
|
+
// of some components and false of others — and only the note can say which.
|
|
1780
|
+
if (outcome.missing.length > 0) {
|
|
1781
|
+
notes.push(`pre-image source is INCOMPLETE — the org returned no source for ${outcome.missing
|
|
1782
|
+
.map((m) => `${m.typeName}:${m.fullName}`)
|
|
1783
|
+
.join(", ")}. Those component(s) have NO deployable rollback image; the rest do.`);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
// 2. Lint, grounded the SAME way direct_lint grounds by default: every projection this session
|
|
1788
|
+
// holds of the target org, freshest observation per component, with this wave's own
|
|
1789
|
+
// pre-projection applied last as the newest read there is. One grounding path for both tiers is
|
|
1790
|
+
// the point — CF63 w4 was apply and lint unioning different sources and disagreeing about what
|
|
1791
|
+
// exists on identical input, and CF79 w2 was apply-time lint warning "no precedent — widen the
|
|
1792
|
+
// projection" at a session that had already widened it.
|
|
1793
|
+
// The bundle sits at <session>/direct/<wave>, so the session root is two levels up.
|
|
1794
|
+
const sessionRoot = path.resolve(opts.bundleDir, "..", "..");
|
|
1795
|
+
const grounding = await collectSessionProjection(sessionRoot, opts.targetOrg, [{ dir: preDir, manifest: pre.manifest }]);
|
|
1796
|
+
const lint = runLint(opts.edits, viewKnownTokens(grounding), opts.acks, await viewProvenValues(grounding), opts.plannedTokens ?? null);
|
|
1797
|
+
// 2-ref. THE REFERENTIAL TIER'S ORG HALF (QA round 1, item 10). The lint just classified every
|
|
1798
|
+
// unknown reference against projections and the batch plan — both of which are somebody's chosen
|
|
1799
|
+
// scope, not the org. Before an unknown decides anything, ask the org itself: a token it proves
|
|
1800
|
+
// PRESENT was only ever outside the projection (CF72's false positives) and stops being a
|
|
1801
|
+
// finding; one it proves ABSENT is a typo or a wrong relationship name about to deploy broken,
|
|
1802
|
+
// and that blocks (ackable). Every failure to answer fails OPEN to the pre-existing warning.
|
|
1803
|
+
// A planned-but-unlanded dependency needs no org read — the plan already explains it — but on a
|
|
1804
|
+
// REAL deploy it means the creator has not run, so it escalates the same way.
|
|
1805
|
+
{
|
|
1806
|
+
const flagged = referentialTokens(lint);
|
|
1807
|
+
if (flagged.length > 0) {
|
|
1808
|
+
const verification = await orgCallPhase("refVerify", () => verifyRefsAgainstOrg(flagged, candidateObjects(opts.edits, grounding.objects), opts.reader));
|
|
1809
|
+
notes.push(...applyRefVerification(lint, verification, opts.acks).notes);
|
|
1810
|
+
}
|
|
1811
|
+
if (!opts.dryRunOnly)
|
|
1812
|
+
escalatePendingDependencies(lint, opts.acks);
|
|
1813
|
+
}
|
|
1814
|
+
// 2a. THE STATIC DROP PREVIEW, over the earlier authored files this batch holds.
|
|
1815
|
+
//
|
|
1816
|
+
// Scoped to components the org does NOT already have, and that is the whole rule: once the earlier
|
|
1817
|
+
// state has landed, 2b below compares against the ORG rather than against a file, which is strictly
|
|
1818
|
+
// better evidence about the same question. Running both would report one omission twice, under two
|
|
1819
|
+
// rule names, with the weaker one arriving first.
|
|
1820
|
+
//
|
|
1821
|
+
// So this covers exactly the window 2b cannot: a wave whose predecessor has not been deployed yet
|
|
1822
|
+
// (an out-of-order apply, a `dryRunOnly` rehearsal of the whole ladder, or simply the first look at
|
|
1823
|
+
// a batch), where the org has nothing to compare against and the batch does.
|
|
1824
|
+
const preFound = new Set(pre.manifest.components.filter((c) => c.found).map((c) => `${c.typeName}:${c.fullName}`));
|
|
1825
|
+
lint.findings.push(...previewPredecessorDrops(opts.edits, await loadPredecessorPreImages((opts.revisions ?? []).filter((r) => !preFound.has(`${r.typeName}:${r.fullName}`)))));
|
|
1826
|
+
// 2a-ii. The destructive path's one static warning. Deletes never reach `runLint` — it takes
|
|
1827
|
+
// authored FILES, and a delete has none — so this is the only place before the org round-trip
|
|
1828
|
+
// where a delete member can be looked at at all.
|
|
1829
|
+
lint.findings.push(...listViewDeleteWarnings(opts.deletes));
|
|
1830
|
+
// 2b. PRE-FLIGHT full-file-replace check.
|
|
1831
|
+
//
|
|
1832
|
+
// `removals` already catches this — but AFTER the deploy, which for a first replace is after the
|
|
1833
|
+
// content is gone. CF76 authored a Layout that omitted CurrencyIsoCode on a multi-currency org,
|
|
1834
|
+
// next to a Currency field; nothing in the toolchain would have caught it, and it was found only
|
|
1835
|
+
// because the supervisor hand-diffed the org image against the authored file — a step that appears
|
|
1836
|
+
// nowhere in the skill.
|
|
1837
|
+
//
|
|
1838
|
+
// The pre-image is already on disk by now, so the same comparison runs BEFORE the deploy. It is
|
|
1839
|
+
// noisier than the post-deploy one by construction: that compares two org read-backs, this
|
|
1840
|
+
// compares an org read-back against parsed intent, so the org's materialized defaults can look
|
|
1841
|
+
// like losses. Hence the split the removals module already draws — UNSTATED removals (a facet the
|
|
1842
|
+
// author never mentioned, about to be wiped) block and are ackable; STATED ones, where the
|
|
1843
|
+
// author's own replacement carries less, only warn.
|
|
1844
|
+
// 2b-i. The drop check's own precondition: a pre-image with a facet the read path could NOT
|
|
1845
|
+
// answer for cannot support "nothing else would be lost". Raised BEFORE the drop findings so the
|
|
1846
|
+
// reader sees the caveat before the clean-looking list it qualifies.
|
|
1847
|
+
for (const risk of incompleteFacetRisks(opts.edits, pre.manifest)) {
|
|
1848
|
+
const acked = opts.acks.includes("PREFLIGHT_PREIMAGE_FACET_INCOMPLETE");
|
|
1849
|
+
const blocks = risk.proven && !acked;
|
|
1850
|
+
lint.findings.push({
|
|
1851
|
+
rule: "PREFLIGHT_PREIMAGE_FACET_INCOMPLETE",
|
|
1852
|
+
severity: blocks ? "error" : "warning",
|
|
1853
|
+
component: risk.component,
|
|
1854
|
+
message: `The pre-image for this component is INCOMPLETE: the org read could not answer for ${risk.facets.join(", ")}. ` +
|
|
1855
|
+
`${risk.reasons.join(" ")} ` +
|
|
1856
|
+
`${risk.typeName} ${risk.proven ? "IS full-file replace (MEASURED on a live org)" : "may be full-file replace (SUSPECTED)"}, ` +
|
|
1857
|
+
`so the org will keep only what your file carries — and the pre-flight drop check below CANNOT tell you whether this ` +
|
|
1858
|
+
`file drops anything from ${risk.facets.join(", ")}, because that content is not in the image it compares against. ` +
|
|
1859
|
+
`A clean drop report is therefore not evidence for ${risk.facets.join(", ")}. ` +
|
|
1860
|
+
`Read the component's DEPLOYABLE source XML under ${path.join(preDir, "source")} — a retrieve, which does carry these ` +
|
|
1861
|
+
`facets faithfully — and carry them forward. Ack PREFLIGHT_PREIMAGE_FACET_INCOMPLETE once you have.`,
|
|
1862
|
+
ackable: true
|
|
1863
|
+
});
|
|
1864
|
+
if (blocks)
|
|
1865
|
+
lint.blocked = true;
|
|
1866
|
+
}
|
|
1867
|
+
const preByKey = new Map(pre.manifest.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
1868
|
+
for (const edit of opts.edits) {
|
|
1869
|
+
// THE TYPE GATE, AND ITS BOUNDARY IS DELIBERATE. A declared `create` on a component that already
|
|
1870
|
+
// exists is a hazard for EVERY type, but this loop is not where the general case belongs: a merge
|
|
1871
|
+
// type keeps what the file omits, so there is nothing here to name, and the finding would be a
|
|
1872
|
+
// claim about ownership rather than about content loss. `plan_waves` raises that one at plan time,
|
|
1873
|
+
// against the session projection, before any org round-trip is spent (R7). What survives here is
|
|
1874
|
+
// the replace-type SUBSET, where the create is also a wholesale overwrite of real content.
|
|
1875
|
+
if (!FULL_FILE_REPLACE_TYPES.has(edit.typeName))
|
|
1876
|
+
continue;
|
|
1877
|
+
const entry = preByKey.get(`${edit.typeName}:${edit.fullName}`);
|
|
1878
|
+
// Nothing in the org, so nothing to lose. EVIDENCE decides this, never the declared intent —
|
|
1879
|
+
// which is the point of reading it here at all. The exclusion used to be justified as "a create
|
|
1880
|
+
// cannot lose anything", and that sentence is true of a create the org agrees is new; a declared
|
|
1881
|
+
// create whose component the org ALREADY HOLDS is precisely the case that must reach the check
|
|
1882
|
+
// below, because it deploys a full-file replacement over content its author does not know is
|
|
1883
|
+
// there. Symmetrically, a declared `update` the org does not hold still drops nothing.
|
|
1884
|
+
if (!entry?.found)
|
|
1885
|
+
continue;
|
|
1886
|
+
// What the wave request said this file was for. Absent on inline corrections and hand-authored
|
|
1887
|
+
// waves, and everything below degrades to the existing wording when it is.
|
|
1888
|
+
const declaredCreate = revisionByKey.get(`${edit.typeName}:${edit.fullName}`)?.intent === "create";
|
|
1889
|
+
const intent = normalizeIntent(edit.typeName, edit.xml);
|
|
1890
|
+
if (!intent)
|
|
1891
|
+
continue; // unparseable XML is already its own finding
|
|
1892
|
+
const preSnap = await fs
|
|
1893
|
+
.readFile(path.join(preDir, entry.file), "utf8")
|
|
1894
|
+
.then((t) => JSON.parse(t))
|
|
1895
|
+
.catch(() => null);
|
|
1896
|
+
// A full-file replace keeps exactly what the file carries, so the authored intent IS the
|
|
1897
|
+
// post-image for this purpose.
|
|
1898
|
+
const wouldDrop = findRemovals(preSnap?.state.metadata ?? null, intent, intent).filter((r) => !isSourceFormatArtifact(r));
|
|
1899
|
+
if (wouldDrop.length === 0)
|
|
1900
|
+
continue;
|
|
1901
|
+
const unstated = wouldDrop.filter((r) => !r.stated);
|
|
1902
|
+
const rule = unstated.length > 0 ? "PREFLIGHT_REPLACE_DROPS_UNSTATED" : "PREFLIGHT_REPLACE_DROPS_STATED";
|
|
1903
|
+
const acked = opts.acks.includes(rule);
|
|
1904
|
+
const proven = PROVEN_REPLACE_TYPES.has(edit.typeName);
|
|
1905
|
+
// Blocking only where the loss is MEASURED. On a proven type an unstated drop is a certainty,
|
|
1906
|
+
// not a risk, so refusing is the accurate response; on a suspected one it is still a guess.
|
|
1907
|
+
const blocks = proven && unstated.length > 0 && !acked;
|
|
1908
|
+
lint.findings.push({
|
|
1909
|
+
rule,
|
|
1910
|
+
severity: blocks ? "error" : "warning",
|
|
1911
|
+
component: `${edit.typeName}:${edit.fullName}`,
|
|
1912
|
+
message:
|
|
1913
|
+
// TWO WORDINGS, ONE CHECK. The prose below was written for an EDIT — "already exists" is
|
|
1914
|
+
// news to nobody who meant to change it, and "anything you did not mean to lose" assumes the
|
|
1915
|
+
// author knew there was something there. Neither sentence fits a file whose plan entry says
|
|
1916
|
+
// this component is being brought into being: that author believes the org has nothing, so
|
|
1917
|
+
// the finding has to lead with the fact that it does, and the fix is a different one (declare
|
|
1918
|
+
// the entry an update and author the complete desired state, rather than carry facets
|
|
1919
|
+
// forward into a create that should not be a create).
|
|
1920
|
+
(declaredCreate
|
|
1921
|
+
? `This wave declares ${edit.typeName}:${edit.fullName} as NEW, but the org ALREADY HAS it — and ` +
|
|
1922
|
+
`${edit.typeName} ${proven ? "IS full-file replace (MEASURED on a live org, not inferred)" : "may be full-file replace (SUSPECTED — never probed)"}, ` +
|
|
1923
|
+
"so this deploys over what is there rather than beside it. The org keeps only what this file carries, and it does not carry " +
|
|
1924
|
+
`${wouldDrop.length} thing(s)${unstated.length > 0 ? `, ${unstated.length} of them never mentioned in your file` : ""}:\n`
|
|
1925
|
+
: `${edit.typeName} ${proven ? "IS full-file replace (MEASURED on a live org, not inferred)" : "may be full-file replace (SUSPECTED — never probed)"} and already exists: the org keeps only what this file carries. ` +
|
|
1926
|
+
`This deploy would drop ${wouldDrop.length} thing(s)${unstated.length > 0 ? `, ${unstated.length} of them never mentioned in your file` : ""}:\n`) +
|
|
1927
|
+
renderRemovals(`${edit.typeName}:${edit.fullName}`, wouldDrop) +
|
|
1928
|
+
`\n${proven ? "These WILL be gone." : "If the type does replace wholesale, these are gone."} ` +
|
|
1929
|
+
(declaredCreate
|
|
1930
|
+
? "Decide which is stale first: if the component is genuinely meant to CHANGE, give its plan.json entry " +
|
|
1931
|
+
'intent "update" and author its complete desired state; if the org copy is the stale one, this file has to ' +
|
|
1932
|
+
"carry everything worth keeping. "
|
|
1933
|
+
: "") +
|
|
1934
|
+
`Re-read the pre-image under ${preDir} — ` +
|
|
1935
|
+
`a DEPLOYABLE source-XML copy of the current component is under ${path.join(preDir, "source")} when the retrieve succeeded; edit that rather than transcribing JSON — ` +
|
|
1936
|
+
`and carry forward anything you did not mean to lose. Ack ${rule} once you have checked.`,
|
|
1937
|
+
ackable: true
|
|
1938
|
+
});
|
|
1939
|
+
if (blocks)
|
|
1940
|
+
lint.blocked = true;
|
|
1941
|
+
}
|
|
1942
|
+
await writeJson(opts.bundleDir, "lint.json", lint);
|
|
1943
|
+
if (lint.blocked) {
|
|
1944
|
+
const result = { verdict: "LINT_BLOCKED", bundleDir: opts.bundleDir, lint, notes };
|
|
1945
|
+
result.operatorSummary = operatorSummaryFor(result);
|
|
1946
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
1947
|
+
return result;
|
|
1948
|
+
}
|
|
1949
|
+
// 3. Temp SFDX project
|
|
1950
|
+
const projectDir = await stageDeployProject(opts.edits, opts.apiVersion);
|
|
1951
|
+
try {
|
|
1952
|
+
// 3b. Keep the EXACT tree that is about to be submitted. The temp project is deleted in the
|
|
1953
|
+
// `finally` below, so until now the deployed bytes survived only inside request.json's `xml`
|
|
1954
|
+
// strings — recoverable in principle, but not as files, and not at the paths Salesforce saw.
|
|
1955
|
+
// The mirror overlays this directory as `source/` (plan 2026-08-02-002 U2), and a rollback
|
|
1956
|
+
// redeploys from it. Written BEFORE the deploy so a rejected wave still shows what was sent.
|
|
1957
|
+
// Bookkeeping: a failure here costs a note and never a deploy.
|
|
1958
|
+
const deployTree = path.join(projectDir, "force-app", "main", "default");
|
|
1959
|
+
try {
|
|
1960
|
+
// CLEARED first, not merged into. A retry re-uses the same bundle directory (that is what
|
|
1961
|
+
// makes `direct_lint {projection: "<label>"}` resolve), so a copy over the top would leave
|
|
1962
|
+
// attempt 1's components sitting beside attempt 2's — and the mirror would then commit source
|
|
1963
|
+
// XML for components this wave never deployed.
|
|
1964
|
+
await fs.rm(path.join(opts.bundleDir, "deployed"), { recursive: true, force: true });
|
|
1965
|
+
if (await fs.stat(deployTree).then((s) => s.isDirectory()).catch(() => false)) {
|
|
1966
|
+
await fs.cp(deployTree, path.join(opts.bundleDir, "deployed"), { recursive: true });
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
catch (error) {
|
|
1970
|
+
notes.push(`could not persist the deployed source tree into the bundle (${error instanceof Error ? error.message : String(error)}) — the deploy is unaffected; org history for this wave will carry no source XML`);
|
|
1971
|
+
}
|
|
1972
|
+
// 4. checkOnly — the authoritative rejection tier. Read-only server-side; retry once on timeout.
|
|
1973
|
+
// Hoisted out of the block below so step 4c can report WHAT was validated: a wave with no file
|
|
1974
|
+
// components reaches a dry-run result having had nothing checked at all, which is a different
|
|
1975
|
+
// answer from a clean one.
|
|
1976
|
+
//
|
|
1977
|
+
// SKIPPABLE, OFF BY DEFAULT, AND ONLY FOR EDIT-ONLY WAVES — see checkOnlyIsSkippable. The
|
|
1978
|
+
// corpus makes the safety case (0 real-deploy failures after a clean dry run in 121 applies,
|
|
1979
|
+
// rollbackOnError:true on all 86) but says nothing about the case that has never been run, so
|
|
1980
|
+
// this ships dark: `CC_MCP_SKIP_CHECKONLY=1` is the only way to take it, and a wave carrying
|
|
1981
|
+
// deletes or facet updates keeps its checkOnly whatever the env says.
|
|
1982
|
+
let validated;
|
|
1983
|
+
const skipCheckOnly = checkOnlyIsSkippable(opts, process.env);
|
|
1984
|
+
if (skipCheckOnly) {
|
|
1985
|
+
notes.push("checkOnly SKIPPED (CC_MCP_SKIP_CHECKONLY=1, edit-only wave): the real deploy below is the only submission. Salesforce runs it with rollbackOnError, so a rejection is atomic — but its failures arrive from the deploy rather than from a practice run, and nothing validated this package before it was submitted for real.");
|
|
1986
|
+
}
|
|
1987
|
+
if (opts.edits.length > 0 && !skipCheckOnly) {
|
|
1988
|
+
let dry = await orgCallPhase("checkOnly", () => sfDeploy(projectDir, opts.targetOrg, true, deployTimeout));
|
|
1989
|
+
if (dry.timedOut) {
|
|
1990
|
+
dry = await orgCallPhase("checkOnly", () => sfDeploy(projectDir, opts.targetOrg, true, deployTimeout));
|
|
1991
|
+
}
|
|
1992
|
+
await writeJson(opts.bundleDir, "dryrun.json", dry);
|
|
1993
|
+
if (dry.status !== "Succeeded") {
|
|
1994
|
+
const result = {
|
|
1995
|
+
verdict: "REJECTED_BY_ORG",
|
|
1996
|
+
bundleDir: opts.bundleDir,
|
|
1997
|
+
lint,
|
|
1998
|
+
dryRun: { status: dry.status, failures: dry.failures },
|
|
1999
|
+
notes
|
|
2000
|
+
};
|
|
2001
|
+
result.operatorSummary = operatorSummaryFor(result);
|
|
2002
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
2003
|
+
return result;
|
|
2004
|
+
}
|
|
2005
|
+
validated = { status: dry.status, failures: [] };
|
|
2006
|
+
}
|
|
2007
|
+
// 4c. dryRunOnly stops HERE — outside the edits guard, which is where it belongs.
|
|
2008
|
+
//
|
|
2009
|
+
// It used to sit inside `if (opts.edits.length > 0)` above, so a wave carrying only deletes or
|
|
2010
|
+
// only objectFacetUpdates fell straight past it and performed REAL WRITES on the option whose
|
|
2011
|
+
// whole promise is "deploy nothing" (step 6b's comment said "skipped in dryRunOnly, which
|
|
2012
|
+
// returned above" — true only when edits existed). Deletes are the worse half: a delete-only
|
|
2013
|
+
// dry run destroyed components.
|
|
2014
|
+
//
|
|
2015
|
+
// The guard is structural rather than a condition repeated at each write site, because the next
|
|
2016
|
+
// mutation step added to this loop would need the same one and the omission is silent.
|
|
2017
|
+
if (opts.dryRunOnly) {
|
|
2018
|
+
// DELETES ARE CHECKABLE. `--metadata-dir` accepts `--dry-run`, so the destructive package goes
|
|
2019
|
+
// to the org as a real checkOnly and comes back with a per-member verdict: validated, blocked
|
|
2020
|
+
// by a live reference (the org names the referrer), or not found. Nothing is destroyed — the
|
|
2021
|
+
// measured proof is that a dry-run of a deletable field left the field in place, and that the
|
|
2022
|
+
// same package with and without `--dry-run` produced identical failures.
|
|
2023
|
+
//
|
|
2024
|
+
// What is still unvalidatable is objectFacetUpdates, and only that: a CRUD read-modify-write
|
|
2025
|
+
// has no practice mode at all. Those remain NOT VALIDATED, which is now a statement about one
|
|
2026
|
+
// path rather than a blanket over two.
|
|
2027
|
+
let destructiveRaw;
|
|
2028
|
+
let destructive;
|
|
2029
|
+
if (opts.deletes.length > 0) {
|
|
2030
|
+
destructiveRaw = await orgCallPhase("destructiveCheckOnly", () => sfDestructiveDeploy(opts.deletes, opts.targetOrg, opts.apiVersion, true, deployTimeout, projectDir));
|
|
2031
|
+
destructive = parseDestructiveResult(destructiveRaw, opts.deletes, "dry-run");
|
|
2032
|
+
await writeJson(opts.bundleDir, "deletes.json", {
|
|
2033
|
+
mode: "dry-run",
|
|
2034
|
+
status: destructive.status,
|
|
2035
|
+
failed: destructive.failed,
|
|
2036
|
+
outcomes: destructive.outcomes,
|
|
2037
|
+
unattributed: destructive.unattributed,
|
|
2038
|
+
raw: destructiveRaw
|
|
2039
|
+
});
|
|
2040
|
+
}
|
|
2041
|
+
const unvalidatedFacets = opts.facetUpdates.map((fu) => ({
|
|
2042
|
+
object: fu.objectApiName,
|
|
2043
|
+
status: "NOT VALIDATED — dryRunOnly. objectFacetUpdates go through a CRUD read-modify-write, which " +
|
|
2044
|
+
"Salesforce offers no validation step for, so nothing was submitted for this object and nothing " +
|
|
2045
|
+
"here says whether it would work. Drop dryRunOnly to apply it for real; the post-deploy read-back " +
|
|
2046
|
+
"is what verifies this path."
|
|
2047
|
+
}));
|
|
2048
|
+
// One status over both halves. A destructive package can come back `Succeeded` while a member
|
|
2049
|
+
// inside it warned (that is what `--ignore-warnings` would produce, and the reason the warning
|
|
2050
|
+
// is read directly), so this is derived from the per-member verdicts, never copied from either
|
|
2051
|
+
// package's status field.
|
|
2052
|
+
const deletesOk = !destructive || !destructive.failed;
|
|
2053
|
+
const filesOk = !validated || validated.status === "Succeeded";
|
|
2054
|
+
const dryRun = validated || destructiveRaw
|
|
2055
|
+
? {
|
|
2056
|
+
status: filesOk && deletesOk ? "Succeeded" : "Failed",
|
|
2057
|
+
failures: [...(validated?.failures ?? []), ...(destructiveRaw?.failures ?? [])],
|
|
2058
|
+
checked: [
|
|
2059
|
+
...(validated ? ["files"] : []),
|
|
2060
|
+
...(destructiveRaw ? ["deletes"] : [])
|
|
2061
|
+
]
|
|
2062
|
+
}
|
|
2063
|
+
: undefined;
|
|
2064
|
+
// A delete the org would refuse is a rejection, not a clean practice run — the same answer a
|
|
2065
|
+
// refused file deploy gets. Returning VALIDATED here would put the word an operator quotes on
|
|
2066
|
+
// top of a deletion Salesforce has already said it will not perform.
|
|
2067
|
+
const result = {
|
|
2068
|
+
verdict: destructive?.failed ? "REJECTED_BY_ORG" : "VALIDATED",
|
|
2069
|
+
bundleDir: opts.bundleDir,
|
|
2070
|
+
lint,
|
|
2071
|
+
...(dryRun ? { dryRun } : {}),
|
|
2072
|
+
...(destructive ? { deletes: destructive.outcomes } : {}),
|
|
2073
|
+
...(unvalidatedFacets.length ? { facetUpdates: unvalidatedFacets } : {}),
|
|
2074
|
+
notes: [
|
|
2075
|
+
...notes,
|
|
2076
|
+
"dryRunOnly — nothing deployed, nothing deleted",
|
|
2077
|
+
...(destructive
|
|
2078
|
+
? [
|
|
2079
|
+
`${destructive.outcomes.length} delete(s) submitted to Salesforce as a destructive checkOnly ` +
|
|
2080
|
+
`(package status ${destructive.status}); per-component verdicts are in the result and in ` +
|
|
2081
|
+
`deletes.json.`
|
|
2082
|
+
]
|
|
2083
|
+
: []),
|
|
2084
|
+
...(destructive?.packageNote ? [destructive.packageNote] : []),
|
|
2085
|
+
...(unvalidatedFacets.length
|
|
2086
|
+
? [
|
|
2087
|
+
`${unvalidatedFacets.length} object facet update(s) were NOT submitted anywhere: a CRUD ` +
|
|
2088
|
+
`read-modify-write has no validation step, so a clean result here proves nothing about them. ` +
|
|
2089
|
+
`They were also not applied — re-run without dryRunOnly and read the post-deploy verification.`
|
|
2090
|
+
]
|
|
2091
|
+
: [])
|
|
2092
|
+
]
|
|
2093
|
+
};
|
|
2094
|
+
result.operatorSummary = operatorSummaryFor(result);
|
|
2095
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
2096
|
+
return result;
|
|
2097
|
+
}
|
|
2098
|
+
// 4b. History-tracking BEFORE image. trackHistory is invisible to the Metadata API, so the
|
|
2099
|
+
// pre-projection on disk cannot carry it and the pre/post comparison below cannot see it move.
|
|
2100
|
+
// The Tooling API is the only surface that reports it, which makes this the one facet needing
|
|
2101
|
+
// its own reading on each side of the deploy. Taken here, after the dry-run-only path has
|
|
2102
|
+
// returned, so a validation costs nothing — and before the mutation window opens, so it is
|
|
2103
|
+
// unambiguously the pre-image.
|
|
2104
|
+
const preTrackedHistory = await orgCallPhase("historyProbe", () => probeFieldHistoryTracking(opts.reader, opts.edits));
|
|
2105
|
+
// 5. Real deploy — never auto-retried, but no longer abandoned either.
|
|
2106
|
+
//
|
|
2107
|
+
// A timeout used to be the end of the story: `runProcess` SIGKILLs at `deployTimeoutMs` and we
|
|
2108
|
+
// returned TIMED_OUT, which is AMBIGUOUS_DELIVERY and a re-projection by hand. The kill only ever
|
|
2109
|
+
// destroyed OUR WATCHER — the Salesforce job kept running — and the CLI had already written that
|
|
2110
|
+
// job's id to `~/.sf/deploy-cache.json` before it began polling. `sfDeploy(..., {notes})` now
|
|
2111
|
+
// recovers it: report on the job, and cancel it if it is still writing. See sfDeploy and
|
|
2112
|
+
// recoverTimedOutDeploy.
|
|
2113
|
+
//
|
|
2114
|
+
// The MUTATION WINDOW opens here. Everything before this point is read-only (the projection and
|
|
2115
|
+
// the checkOnly validation), so this is the earliest instant at which the org can have changed
|
|
2116
|
+
// because of us. It closes after the facet updates below — the last write this call makes.
|
|
2117
|
+
// Recorded to the second and persisted, because the change scan (plan -002) asks the org "what
|
|
2118
|
+
// moved between these two timestamps", and a window that is guessed after the fact is a window
|
|
2119
|
+
// that either misses real changes or blames us for someone else's. That window is precisely what
|
|
2120
|
+
// an ABANDONED deploy corrupts, which is why the recovery path cancels rather than merely
|
|
2121
|
+
// reporting.
|
|
2122
|
+
const mutationWindow = { start: new Date().toISOString(), end: "" };
|
|
2123
|
+
let deploy;
|
|
2124
|
+
if (opts.edits.length > 0) {
|
|
2125
|
+
deploy = await orgCallPhase("deploy", () => sfDeploy(projectDir, opts.targetOrg, false, deployTimeout, { notes }));
|
|
2126
|
+
await writeJson(opts.bundleDir, "deploy.json", deploy);
|
|
2127
|
+
if (!deploy.timedOut && deploy.status !== "Succeeded") {
|
|
2128
|
+
const result = {
|
|
2129
|
+
verdict: "REJECTED_BY_ORG",
|
|
2130
|
+
bundleDir: opts.bundleDir,
|
|
2131
|
+
lint,
|
|
2132
|
+
deploy: { status: deploy.status, id: deploy.id, failures: deploy.failures },
|
|
2133
|
+
notes: [
|
|
2134
|
+
...notes,
|
|
2135
|
+
skipCheckOnly
|
|
2136
|
+
? "deploy REJECTED, and this wave skipped its checkOnly (CC_MCP_SKIP_CHECKONLY=1) — so this rejection arrives from the real submission rather than from a practice run. Salesforce deployed with rollbackOnError, so nothing partial landed; the failures above are the org's, in the same shape a dry run would have given them."
|
|
2137
|
+
: "real deploy failed after a clean dry-run — org state may have moved between stages"
|
|
2138
|
+
]
|
|
2139
|
+
};
|
|
2140
|
+
result.operatorSummary = operatorSummaryFor(result);
|
|
2141
|
+
await writeJson(opts.bundleDir, "verdict.json", result);
|
|
2142
|
+
return result;
|
|
2143
|
+
}
|
|
2144
|
+
// The recovery path already pushed the note that says WHICH kind of timeout this was — the job
|
|
2145
|
+
// was cancelled, or it could not be identified, or it could not be reported on. A blanket line
|
|
2146
|
+
// here would talk over the specific one.
|
|
2147
|
+
}
|
|
2148
|
+
// 6. Destructive deletes via an mdapi destructive deploy (no CRUD connection needed).
|
|
2149
|
+
const deleteOutcomes = [];
|
|
2150
|
+
if (opts.deletes.length > 0) {
|
|
2151
|
+
const raw = await orgCallPhase("destructive", () => sfDestructiveDeploy(opts.deletes, opts.targetOrg, opts.apiVersion, false, deployTimeout, projectDir));
|
|
2152
|
+
const status = raw.timedOut ? "TIMED_OUT (AMBIGUOUS — post-projection is the authority)" : raw.status;
|
|
2153
|
+
for (const d of opts.deletes)
|
|
2154
|
+
deleteOutcomes.push({ component: `${d.typeName}:${d.fullName}`, status });
|
|
2155
|
+
await writeJson(opts.bundleDir, "deletes.json", { mode: "real", status, outcomes: deleteOutcomes, raw });
|
|
2156
|
+
}
|
|
2157
|
+
// 6b. Parent-object facet updates via CRUD read-modify-write (skipped in dryRunOnly,
|
|
2158
|
+
// which returned above). Each is read-back verified by the post-projection intent stage.
|
|
2159
|
+
const facetOutcomes = [];
|
|
2160
|
+
if (opts.facetUpdates.length > 0) {
|
|
2161
|
+
const { updateObjectFacets } = await import("./crud.js");
|
|
2162
|
+
for (const fu of opts.facetUpdates) {
|
|
2163
|
+
try {
|
|
2164
|
+
await orgCallPhase("facetUpdate", () => updateObjectFacets(opts.targetOrg, opts.apiVersion, fu.objectApiName, fu.facets));
|
|
2165
|
+
// SUBMITTED, not "updated". All that is known here is that the CRUD Metadata API answered
|
|
2166
|
+
// success:true, which acknowledges the REQUEST — Salesforce returns it for facets it
|
|
2167
|
+
// silently ignores. Live-caught CF63 w1: sharingModel reported success TWICE while
|
|
2168
|
+
// EntityDefinition.InternalSharingModel stayed ReadWrite. The read-back caught it, but the
|
|
2169
|
+
// response also carried `status: "updated"` next to that mismatch, so one payload made
|
|
2170
|
+
// two contradictory claims and the operator had to notice the disagreement.
|
|
2171
|
+
//
|
|
2172
|
+
// The verified word is written below, after the post-projection has actually looked.
|
|
2173
|
+
facetOutcomes.push({ object: fu.objectApiName, status: "submitted" });
|
|
2174
|
+
}
|
|
2175
|
+
catch (error) {
|
|
2176
|
+
facetOutcomes.push({ object: fu.objectApiName, status: `FAILED: ${error instanceof Error ? error.message : String(error)}` });
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
await writeJson(opts.bundleDir, "facet-updates.json", facetOutcomes);
|
|
2180
|
+
}
|
|
2181
|
+
// The mutation window closes: every write this call makes has now been issued. Padded by a
|
|
2182
|
+
// second on each side because Salesforce stamps LastModifiedDate to the second, so a change
|
|
2183
|
+
// made in the same second as the boundary would otherwise fall outside a strict comparison.
|
|
2184
|
+
mutationWindow.end = new Date(Date.now() + 1000).toISOString();
|
|
2185
|
+
mutationWindow.start = new Date(Date.parse(mutationWindow.start) - 1000).toISOString();
|
|
2186
|
+
await writeJson(opts.bundleDir, "mutation-window.json", mutationWindow);
|
|
2187
|
+
// 7. Post-projection + three-way diff. The mutations above (deploy/deletes/facets) have
|
|
2188
|
+
// ALREADY landed; a network failure in this READ-BACK phase must not crash the call and
|
|
2189
|
+
// throw them away. On any error here we return AMBIGUOUS_DELIVERY (deploy id preserved) —
|
|
2190
|
+
// the operator re-projects to confirm. (6.5.1 run: a `fetch failed` here discarded a
|
|
2191
|
+
// QuickAction deploy that had actually succeeded.)
|
|
2192
|
+
try {
|
|
2193
|
+
const postDir = path.join(opts.bundleDir, "post");
|
|
2194
|
+
// Its own phase rather than sharing the pre-projection's, because the two are not the same
|
|
2195
|
+
// cost on the same scope: post is measured at 3.8x pre across the corpus (identical scope, zero
|
|
2196
|
+
// sleeps), and a meter that merged them would hide precisely that asymmetry. A read-back retry
|
|
2197
|
+
// re-enters this, so the phase accumulates across attempts — which is the honest total.
|
|
2198
|
+
const projectPost = async () => {
|
|
2199
|
+
await orgCallPhase("postProjection", () => runProjection(opts.reader, {
|
|
2200
|
+
scope,
|
|
2201
|
+
outDir: postDir,
|
|
2202
|
+
orgAlias: opts.targetOrg,
|
|
2203
|
+
apiVersion: opts.apiVersion,
|
|
2204
|
+
label: "post",
|
|
2205
|
+
// The SAME decision the pre-projection made, and it has to be — an asymmetric skip would
|
|
2206
|
+
// manufacture a side change on every wave (see the pre-projection comment).
|
|
2207
|
+
skipPrecedent
|
|
2208
|
+
}));
|
|
2209
|
+
};
|
|
2210
|
+
await projectPost();
|
|
2211
|
+
// WHAT THE FIRST READ DISAGREED ABOUT — and it is the only read there is. There used to be a
|
|
2212
|
+
// backoff here that re-projected up to five times over 9.5s; it is gone (see the note where
|
|
2213
|
+
// READBACK_RETRY_DELAYS_MS used to live), and this predicate now decides the WORDING of a note
|
|
2214
|
+
// rather than whether to sleep.
|
|
2215
|
+
//
|
|
2216
|
+
// Kept, rather than folded into step 8's intent check, because the two ask different questions:
|
|
2217
|
+
// step 8 reports every mismatch, this one asks whether a mismatch is the org's final answer.
|
|
2218
|
+
// "Unsettled" is deliberately the FULL intent check, not merely "is the component there".
|
|
2219
|
+
//
|
|
2220
|
+
// CF74 produced three distinct shapes of DELIVERED_WITH_DRIFT and only one was absence:
|
|
2221
|
+
// w1 CustomObject read back (absent) — the component itself had not propagated
|
|
2222
|
+
// w4b compactLayoutAssignment read back SYSTEM — component present, facet still the old value
|
|
2223
|
+
// w7 sharingOwnerRules read back [] — present, and recalculating asynchronously
|
|
2224
|
+
// The operator confirmed all three had landed correctly by re-reading moments later. A predicate
|
|
2225
|
+
// that only counted missing components would name w1 and stay silent on the other two, which is
|
|
2226
|
+
// most of the noise the verdict was suffering from.
|
|
2227
|
+
//
|
|
2228
|
+
// Readable only because the permanently-benign class is canonicalized first
|
|
2229
|
+
// (PLATFORM_DEFAULT_WHEN_OMITTED): otherwise a mismatch that can NEVER resolve — an element the
|
|
2230
|
+
// org always omits — would be advertised as worth re-reading on every single apply.
|
|
2231
|
+
const unsettled = async () => {
|
|
2232
|
+
const manifest = JSON.parse(await fs.readFile(path.join(postDir, "projection.json"), "utf8"));
|
|
2233
|
+
const byKey = new Map(manifest.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
2234
|
+
const out = [];
|
|
2235
|
+
for (const edit of opts.edits) {
|
|
2236
|
+
const key = `${edit.typeName}:${edit.fullName}`;
|
|
2237
|
+
const entry = byKey.get(key);
|
|
2238
|
+
if (!entry?.found) {
|
|
2239
|
+
out.push({ component: key, absent: true, paths: [] });
|
|
2240
|
+
continue;
|
|
2241
|
+
}
|
|
2242
|
+
const intent = normalizeIntent(edit.typeName, edit.xml);
|
|
2243
|
+
if (!intent)
|
|
2244
|
+
continue; // unparseable intent is a real finding, not something waiting settles
|
|
2245
|
+
const snap = JSON.parse(await fs.readFile(path.join(postDir, entry.file), "utf8"));
|
|
2246
|
+
const observed = snap.state.metadata;
|
|
2247
|
+
const cmp = compareIntent(coerceLeaf(intent), observed ? coerceLeaf(observed) : null);
|
|
2248
|
+
if (cmp.mismatches.length > 0) {
|
|
2249
|
+
out.push({ component: key, absent: false, paths: cmp.mismatches.map((m) => m.path) });
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
// Facet updates settle like everything else — and until the EV-31…40 run this loop could not
|
|
2253
|
+
// see them, because it walked `edits` only. A facet-only apply therefore never waited for
|
|
2254
|
+
// anything, whatever ASYNC_SETTLING_LEAVES said, and step 8 below scored the FIRST read as
|
|
2255
|
+
// final: a sharingModel change came back "NOT APPLIED — the org accepted the request but
|
|
2256
|
+
// still reads ReadWrite" while the org was mid-recalculation and Private moments later. The
|
|
2257
|
+
// wait and the verdict have to look at the same set of writes.
|
|
2258
|
+
for (const fu of opts.facetUpdates) {
|
|
2259
|
+
const key = `CustomObject:${fu.objectApiName}`;
|
|
2260
|
+
const entry = byKey.get(key);
|
|
2261
|
+
if (!entry?.found) {
|
|
2262
|
+
out.push({ component: `${key} (facets)`, absent: true, paths: [] });
|
|
2263
|
+
continue;
|
|
2264
|
+
}
|
|
2265
|
+
const snap = JSON.parse(await fs.readFile(path.join(postDir, entry.file), "utf8"));
|
|
2266
|
+
const observed = snap.state.metadata;
|
|
2267
|
+
const cmp = compareIntent(coerceLeaf(fu.facets), observed ? coerceLeaf(observed) : null);
|
|
2268
|
+
if (cmp.mismatches.length > 0) {
|
|
2269
|
+
out.push({ component: `${key} (facets)`, absent: false, paths: cmp.mismatches.map((m) => m.path) });
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
return out;
|
|
2273
|
+
};
|
|
2274
|
+
// THE READ-BACK IS ONE READ. Nothing below sleeps or re-projects; `pending` is the first and
|
|
2275
|
+
// only answer, and the two branches differ only in what the operator is told to do with it.
|
|
2276
|
+
const pending = await unsettled();
|
|
2277
|
+
if (pending.length > 0) {
|
|
2278
|
+
const components = pending.map((p) => p.component).join(", ");
|
|
2279
|
+
const paths = [...new Set(pending.flatMap((p) => p.paths))].slice(0, 6).join(", ");
|
|
2280
|
+
notes.push(settlingIsPlausible(pending)
|
|
2281
|
+
? `read-back differed on something Salesforce may still be settling: ${components}` +
|
|
2282
|
+
`${paths ? ` (${paths})` : " (read back absent)"}. The verdict below reports it as drift ` +
|
|
2283
|
+
`immediately rather than sleeping on it — the backoff that used to wait here settled ZERO of the 17 ` +
|
|
2284
|
+
`waves it ever fired on. Re-project the scope (project_org) to see whether it has caught up.`
|
|
2285
|
+
: `read-back concluded immediately: ${components} differed on ${paths}, none of which Salesforce settles ` +
|
|
2286
|
+
`asynchronously — this is the org's answer, not a work in progress. If one of these IS ` +
|
|
2287
|
+
`eventually-consistent, add its leaf name to ASYNC_SETTLING_LEAVES so the note above says so.`);
|
|
2288
|
+
}
|
|
2289
|
+
// Still reported, still always 0. The ledger has 28 waves of `readbackWaitMs` and every one is
|
|
2290
|
+
// zero; emitting the field keeps that series continuous across this build rather than leaving a
|
|
2291
|
+
// hole that reads as "older installs did not send it". `readbackSettled` is deliberately NOT set
|
|
2292
|
+
// — it answered "did waiting help", and nothing waits.
|
|
2293
|
+
const waited = 0;
|
|
2294
|
+
const diff = await diffProjections(preDir, postDir);
|
|
2295
|
+
await writeJson(opts.bundleDir, "diff.json", diff);
|
|
2296
|
+
// 8. Intent check per edited component; deletes must be absent post.
|
|
2297
|
+
const postManifest = JSON.parse(await fs.readFile(path.join(postDir, "projection.json"), "utf8"));
|
|
2298
|
+
const postByKey = new Map(postManifest.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
2299
|
+
const preByKey = new Map(pre.manifest.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
2300
|
+
const intentReports = [];
|
|
2301
|
+
const removalReports = [];
|
|
2302
|
+
const unstatedChangeReports = [];
|
|
2303
|
+
// trackHistory is INVISIBLE to the Metadata API read-back — omitted whatever its value, so a
|
|
2304
|
+
// field deployed with history tracking ON reads back with the facet simply missing. The
|
|
2305
|
+
// comparator (correctly) treats an intended TRUE that comes back absent as drift, which made
|
|
2306
|
+
// every history-tracked field a false DELIVERED_WITH_DRIFT: 5 of them across the local runs.
|
|
2307
|
+
//
|
|
2308
|
+
// Cataloguing it in PLATFORM_DEFAULT_WHEN_OMITTED would silence the alarm by auto-matching any
|
|
2309
|
+
// stated `true` — including when tracking genuinely failed to enable, which is exactly the
|
|
2310
|
+
// masking this verdict exists to prevent. So the value is fetched from the one place the org
|
|
2311
|
+
// does expose it, the Tooling API's FieldDefinition.IsFieldHistoryTracked, and compared for
|
|
2312
|
+
// real. Fail-open: if the probe cannot run, the facet is left as the projection saw it and the
|
|
2313
|
+
// old (noisy but honest) behaviour returns.
|
|
2314
|
+
const trackedHistory = await orgCallPhase("historyProbe", () => probeFieldHistoryTracking(opts.reader, opts.edits));
|
|
2315
|
+
for (const edit of opts.edits) {
|
|
2316
|
+
const key = `${edit.typeName}:${edit.fullName}`;
|
|
2317
|
+
const intent = normalizeIntent(edit.typeName, edit.xml);
|
|
2318
|
+
const entry = postByKey.get(key);
|
|
2319
|
+
let observed = null;
|
|
2320
|
+
// The org's post-image UNTOUCHED. `observed` below gains the probed trackHistory, which the
|
|
2321
|
+
// Metadata API never returns on either side — comparing that against a pre-image that
|
|
2322
|
+
// structurally cannot carry it would manufacture a change on every history-tracked field.
|
|
2323
|
+
let readBack = null;
|
|
2324
|
+
if (entry?.found) {
|
|
2325
|
+
const snap = JSON.parse(await fs.readFile(path.join(postDir, entry.file), "utf8"));
|
|
2326
|
+
observed = snap.state.metadata;
|
|
2327
|
+
readBack = observed;
|
|
2328
|
+
const tracked = trackedHistory.get(edit.fullName);
|
|
2329
|
+
if (edit.typeName === "CustomField" && observed && tracked !== undefined) {
|
|
2330
|
+
observed = { ...observed, trackHistory: tracked };
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
// Both pre/post comparisons below need the pre-image, and both are only possible when the
|
|
2334
|
+
// component already existed — a create has no pre-image, which keeps them quiet and targeted.
|
|
2335
|
+
const preEntry = preByKey.get(key);
|
|
2336
|
+
const unstated = [];
|
|
2337
|
+
if (preEntry?.found && observed) {
|
|
2338
|
+
const preSnap = JSON.parse(await fs.readFile(path.join(preDir, preEntry.file), "utf8"));
|
|
2339
|
+
// Full-file-replace collateral: what did this component LOSE?
|
|
2340
|
+
const lost = findRemovals(preSnap.state.metadata, observed, intent);
|
|
2341
|
+
if (lost.length > 0)
|
|
2342
|
+
removalReports.push({ component: key, removals: lost });
|
|
2343
|
+
// ...and what did it CHANGE that nobody asked for? The other half of the same question, and
|
|
2344
|
+
// the half that shipped green on the EV-31…40 run.
|
|
2345
|
+
unstated.push(...findUnstatedChanges(edit.typeName, preSnap.state.metadata, readBack, intent));
|
|
2346
|
+
}
|
|
2347
|
+
const trackingChange = unstatedTrackingChange(edit, intent, preTrackedHistory, trackedHistory);
|
|
2348
|
+
if (trackingChange)
|
|
2349
|
+
unstated.push(trackingChange);
|
|
2350
|
+
if (unstated.length > 0)
|
|
2351
|
+
unstatedChangeReports.push({ component: key, changes: unstated });
|
|
2352
|
+
if (!intent) {
|
|
2353
|
+
intentReports.push({ component: key, matched: 0, mismatches: [{ path: "xml", intended: "(parseable)", observed: "(unparseable)" }], orderOnly: 0, found: Boolean(entry?.found) });
|
|
2354
|
+
continue;
|
|
2355
|
+
}
|
|
2356
|
+
const cmp = compareIntent(coerceLeaf(intent), observed ? coerceLeaf(observed) : null);
|
|
2357
|
+
intentReports.push({ component: key, ...cmp, found: Boolean(entry?.found) });
|
|
2358
|
+
}
|
|
2359
|
+
for (const fu of opts.facetUpdates) {
|
|
2360
|
+
const key = `CustomObject:${fu.objectApiName}`;
|
|
2361
|
+
const entry = postByKey.get(key);
|
|
2362
|
+
let observed = null;
|
|
2363
|
+
if (entry?.found) {
|
|
2364
|
+
const snap = JSON.parse(await fs.readFile(path.join(postDir, entry.file), "utf8"));
|
|
2365
|
+
observed = snap.state.metadata;
|
|
2366
|
+
}
|
|
2367
|
+
const cmp = compareIntent(coerceLeaf(fu.facets), observed ? coerceLeaf(observed) : null);
|
|
2368
|
+
intentReports.push({ component: `${key} (facets)`, ...cmp, found: Boolean(entry?.found) });
|
|
2369
|
+
// Promote "submitted" to what the org ACTUALLY shows, so `facetUpdates` and `intent` cannot
|
|
2370
|
+
// tell the reader two different stories about the same write (CF63 w1: `status: "updated"`
|
|
2371
|
+
// sitting beside a mismatch proving nothing had changed). The mismatch is named here too —
|
|
2372
|
+
// this line is the one a summary is most likely to quote.
|
|
2373
|
+
const outcome = facetOutcomes.find((f) => f.object === fu.objectApiName);
|
|
2374
|
+
if (outcome && outcome.status === "submitted")
|
|
2375
|
+
outcome.status = facetVerifiedStatus(cmp.mismatches);
|
|
2376
|
+
}
|
|
2377
|
+
if (facetOutcomes.length)
|
|
2378
|
+
await writeJson(opts.bundleDir, "facet-updates.json", facetOutcomes);
|
|
2379
|
+
for (const d of opts.deletes) {
|
|
2380
|
+
const key = `${d.typeName}:${d.fullName}`;
|
|
2381
|
+
const entry = postByKey.get(key);
|
|
2382
|
+
const gone = !entry || !entry.found;
|
|
2383
|
+
intentReports.push({
|
|
2384
|
+
component: key,
|
|
2385
|
+
matched: gone ? 1 : 0,
|
|
2386
|
+
mismatches: gone ? [] : [{ path: "metadata", intended: "(deleted)", observed: "(still present)" }],
|
|
2387
|
+
orderOnly: 0,
|
|
2388
|
+
found: Boolean(entry?.found)
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
await writeJson(opts.bundleDir, "intent.json", intentReports);
|
|
2392
|
+
// Side changes: anything that moved outside the edit/delete set (real changes only).
|
|
2393
|
+
//
|
|
2394
|
+
// Two sources now. The projection diff still catches anything IN scope — which, since the scope
|
|
2395
|
+
// narrowed (plan -002), is essentially the touched set, so it mostly catches added/removed
|
|
2396
|
+
// collateral. The window scan supplies what the wide projection used to: components that moved
|
|
2397
|
+
// during the deploy but were never projected, including on objects nobody put in scope.
|
|
2398
|
+
const facetKeys = new Set(opts.facetUpdates.map((f) => `CustomObject:${f.objectApiName}`));
|
|
2399
|
+
const sideChanges = diff.changed
|
|
2400
|
+
.filter((c) => !editKeys.has(c.key) && !deleteKeys.has(c.key) && !facetKeys.has(c.key))
|
|
2401
|
+
.filter((c) => c.changes.some((ch) => !ch.orderOnly))
|
|
2402
|
+
.map((c) => c.key)
|
|
2403
|
+
.concat(diff.added.filter((k) => !editKeys.has(k)), diff.removed.filter((k) => !deleteKeys.has(k)));
|
|
2404
|
+
const ours = new Set([...editKeys, ...deleteKeys, ...facetKeys]);
|
|
2405
|
+
// Witnesses exclude deletes: a removed component leaves no row to carry a LastModifiedDate, so
|
|
2406
|
+
// counting it would make every delete-only wave report a suspect window.
|
|
2407
|
+
const witnesses = new Set([...editKeys, ...facetKeys]);
|
|
2408
|
+
const scan = await orgCallPhase("changeScan", () => scanChangedInWindow(opts.reader, mutationWindow, ours, witnesses));
|
|
2409
|
+
await writeJson(opts.bundleDir, "change-scan.json", scan);
|
|
2410
|
+
for (const c of scan.candidates)
|
|
2411
|
+
if (!sideChanges.includes(c))
|
|
2412
|
+
sideChanges.push(c);
|
|
2413
|
+
notes.push(...scan.notes);
|
|
2414
|
+
// The scan catches per-type query errors and keeps going, so "found nothing" and "could not
|
|
2415
|
+
// look" produce the same empty candidate list. typesCovered is the only thing that separates
|
|
2416
|
+
// them, and it reached change-scan.json and nowhere else — not the verdict, not the operator
|
|
2417
|
+
// sentence, not the ledger. A scan that queried no types would report DIFF_VERIFIED and say
|
|
2418
|
+
// "every setting matches the specification".
|
|
2419
|
+
const missingTypes = CHANGE_SCAN_TYPES.filter((t) => !scan.typesCovered.includes(t.type)).map((t) => t.type);
|
|
2420
|
+
// Two ways the scan can fail to prove anything, and they must be treated identically: it could
|
|
2421
|
+
// not QUERY some types (missingTypes), or it queried them over the WRONG INTERVAL and so cannot
|
|
2422
|
+
// have seen a concurrent change either (windowSuspect — see ChangeScanResult). Both produce an
|
|
2423
|
+
// empty candidate list that looks exactly like a clean org.
|
|
2424
|
+
const scanIncomplete = missingTypes.length > 0 || scan.windowSuspect;
|
|
2425
|
+
// Guarded on missingTypes, not on scanIncomplete: the window-suspect case has already pushed
|
|
2426
|
+
// its own note (with the far more useful explanation), and this one would otherwise render
|
|
2427
|
+
// "could not query: " with an empty list beside it.
|
|
2428
|
+
if (missingTypes.length > 0) {
|
|
2429
|
+
notes.push(`The org-wide change scan covered ${scan.typesCovered.length} of ${CHANGE_SCAN_TYPES.length} types (could not query: ${missingTypes.join(", ")}). ` +
|
|
2430
|
+
`Nothing here rules out a concurrent change to those types — the check did not complete, which is not the same as finding nothing.`);
|
|
2431
|
+
}
|
|
2432
|
+
if (scan.candidates.length > 0) {
|
|
2433
|
+
notes.push(`${scan.candidates.length} component(s) changed in the org DURING this deploy window that this wave did not deploy ` +
|
|
2434
|
+
`(${scan.candidates.slice(0, 8).join(", ")}${scan.candidates.length > 8 ? ", …" : ""}). ` +
|
|
2435
|
+
`This is "moved while you were deploying", NOT "your deploy moved it" — a concurrent admin or a parallel run looks identical here. ` +
|
|
2436
|
+
`Re-read them before assuming either.`);
|
|
2437
|
+
}
|
|
2438
|
+
await writeJson(opts.bundleDir, "removals.json", removalReports);
|
|
2439
|
+
for (const r of removalReports) {
|
|
2440
|
+
notes.push(`CONTENT REMOVED by this edit — ${renderRemovals(r.component, r.removals)}. A full-file replace deletes whatever the file omits; confirm each loss was intended (re-read the pre-image in ${path.join(opts.bundleDir, "pre")} and restore anything you did not mean to drop).`);
|
|
2441
|
+
}
|
|
2442
|
+
await writeJson(opts.bundleDir, "unstated-changes.json", unstatedChangeReports);
|
|
2443
|
+
for (const u of unstatedChangeReports) {
|
|
2444
|
+
notes.push(`SETTING CHANGED WITHOUT BEING ASKED FOR — ${renderUnstatedChanges(u.component, u.changes)}. ` +
|
|
2445
|
+
`This component moved on a facet your file never states, so nothing verified it: the intent check only reads what you wrote. ` +
|
|
2446
|
+
`Compare the pre- and post-images in ${opts.bundleDir} and confirm each one, then either state the value you want in the file or restore it — ` +
|
|
2447
|
+
`an unstated change is how history tracking and a validation rule's error message were silently altered under a "verified" verdict.`);
|
|
2448
|
+
}
|
|
2449
|
+
// "NOT APPLIED" as well as "FAILED": the first is a facet the org ACCEPTED and then ignored,
|
|
2450
|
+
// which is the quieter and more dangerous of the two. Its mismatch already fails intentClean on
|
|
2451
|
+
// its own; naming it here keeps the verdict from depending on that coincidence.
|
|
2452
|
+
const facetFailed = facetOutcomes.some((f) => f.status.startsWith("FAILED") || f.status.startsWith("NOT APPLIED"));
|
|
2453
|
+
const intentClean = intentReports.every((r) => r.mismatches.length === 0) && !facetFailed;
|
|
2454
|
+
// Removals never qualify as DIFF_VERIFIED: the shape the author stated may be perfectly
|
|
2455
|
+
// matched while the component silently lost content it used to have (live-caught 6.5.4).
|
|
2456
|
+
// Unstated changes block for the same reason from the other direction — the stated shape matches
|
|
2457
|
+
// while a facet nobody wrote about moved (live-caught EV-31…40, under a DIFF_VERIFIED banner).
|
|
2458
|
+
// scanIncomplete blocks DIFF_VERIFIED for the same reason removals do: the components may match
|
|
2459
|
+
// the spec perfectly while something the run cannot see has moved. Verified means verified.
|
|
2460
|
+
const verdict = deploy?.timedOut
|
|
2461
|
+
? "AMBIGUOUS_DELIVERY"
|
|
2462
|
+
: intentClean &&
|
|
2463
|
+
sideChanges.length === 0 &&
|
|
2464
|
+
removalReports.length === 0 &&
|
|
2465
|
+
unstatedChangeReports.length === 0 &&
|
|
2466
|
+
!scanIncomplete
|
|
2467
|
+
? "DIFF_VERIFIED"
|
|
2468
|
+
: "DELIVERED_WITH_DRIFT";
|
|
2469
|
+
const result = {
|
|
2470
|
+
verdict,
|
|
2471
|
+
bundleDir: opts.bundleDir,
|
|
2472
|
+
lint,
|
|
2473
|
+
...(opts.edits.length > 0
|
|
2474
|
+
? {
|
|
2475
|
+
dryRun: { status: "Succeeded", failures: [] },
|
|
2476
|
+
deploy: deploy ? { status: deploy.status, id: deploy.id, failures: deploy.failures, timedOut: deploy.timedOut } : undefined
|
|
2477
|
+
}
|
|
2478
|
+
: {}),
|
|
2479
|
+
...(deleteOutcomes.length ? { deletes: deleteOutcomes } : {}),
|
|
2480
|
+
...(facetOutcomes.length ? { facetUpdates: facetOutcomes } : {}),
|
|
2481
|
+
diff,
|
|
2482
|
+
intent: intentReports,
|
|
2483
|
+
sideChanges,
|
|
2484
|
+
...(removalReports.length ? { removals: removalReports } : {}),
|
|
2485
|
+
...(unstatedChangeReports.length ? { unstatedChanges: unstatedChangeReports } : {}),
|
|
2486
|
+
...(scanIncomplete ? { scanIncomplete: true } : {}),
|
|
2487
|
+
readbackWaitMs: waited,
|
|
2488
|
+
notes
|
|
2489
|
+
};
|
|
2490
|
+
return await recordInMirror(result, opts);
|
|
2491
|
+
}
|
|
2492
|
+
catch (readbackError) {
|
|
2493
|
+
// Only the READ-BACK phase throws here. If nothing was mutated (no deploy/delete/facet),
|
|
2494
|
+
// the error is real — surface it. Otherwise the mutation may have landed → AMBIGUOUS.
|
|
2495
|
+
const mutated = opts.edits.length > 0 || opts.deletes.length > 0 || opts.facetUpdates.length > 0;
|
|
2496
|
+
if (!mutated)
|
|
2497
|
+
throw readbackError;
|
|
2498
|
+
const result = ambiguousReadbackResult({
|
|
2499
|
+
bundleDir: opts.bundleDir,
|
|
2500
|
+
lint,
|
|
2501
|
+
deploy: deploy ? { status: deploy.status, id: deploy.id, failures: deploy.failures, timedOut: deploy.timedOut } : undefined,
|
|
2502
|
+
deletes: deleteOutcomes.length ? deleteOutcomes : undefined,
|
|
2503
|
+
facetUpdates: facetOutcomes.length ? facetOutcomes : undefined,
|
|
2504
|
+
priorNotes: notes,
|
|
2505
|
+
error: readbackError
|
|
2506
|
+
});
|
|
2507
|
+
// An ambiguous read-back has no post-image, so the overlay has only the wave's deletes to
|
|
2508
|
+
// stage and usually commits nothing. That is the honest outcome: R2 says an ambiguous wave
|
|
2509
|
+
// records the components it OBSERVED, and here it observed none.
|
|
2510
|
+
return await recordInMirror(result, opts);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
finally {
|
|
2514
|
+
await fs.rm(projectDir, { recursive: true, force: true });
|
|
2515
|
+
}
|
|
2516
|
+
}
|