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,61 @@
|
|
|
1
|
+
// Generic metadata snapshot — the read path for every type without a dedicated reader.
|
|
2
|
+
// Ported from sf-config-verifier (src/snapshots/customObjectSnapshot.ts).
|
|
3
|
+
import { normalizeCustomFieldMetadata } from "./fieldMetadata.js";
|
|
4
|
+
import { snapshot } from "./snapshotTypes.js";
|
|
5
|
+
// Known boolean keys per typeName (live-caught, EXT-966 customTab gate 2026-06-10): the raw metadata
|
|
6
|
+
// read returns XML leaves as STRINGS ('true'/'false'), so a boolean comparison false-FAILs against
|
|
7
|
+
// an observed "true". Every DEDICATED snapshot coerces its known boolean keys at the read boundary
|
|
8
|
+
// (customFieldSnapshot.asBoolean and friends); this is the generic-family analog.
|
|
9
|
+
//
|
|
10
|
+
// Keyed allowlist — NEVER blanket-coerce. A free-text key (a description, a formula body) can
|
|
11
|
+
// legitimately hold the string 'true', and turning that into a boolean would corrupt a correct
|
|
12
|
+
// read. Extend per typeName as live runs prove new families.
|
|
13
|
+
//
|
|
14
|
+
// The CustomObject row is the same platform quirk that bit buildObjectFacetPayload from the other
|
|
15
|
+
// direction: these facets round-trip as strings, so both the read and the write path have to know.
|
|
16
|
+
const GENERIC_BOOLEAN_KEYS = {
|
|
17
|
+
CustomTab: ["customObject", "hasSidebar", "mobileReady"],
|
|
18
|
+
CustomObject: [
|
|
19
|
+
"enableActivities",
|
|
20
|
+
"enableBulkApi",
|
|
21
|
+
"enableFeeds",
|
|
22
|
+
"enableHistory",
|
|
23
|
+
"enableReports",
|
|
24
|
+
"enableSearch",
|
|
25
|
+
"enableSharing",
|
|
26
|
+
"enableStreamingApi"
|
|
27
|
+
],
|
|
28
|
+
PermissionSet: ["hasActivationRequired"],
|
|
29
|
+
ApprovalProcess: ["active", "showApprovalHistory"],
|
|
30
|
+
// ReportType: `deployed` is a required boolean per the WSDL schema (jsforce
|
|
31
|
+
// lib/api/metadata/schema.d.ts). DOC-DERIVED, never confirmed against a live org — the family's
|
|
32
|
+
// first live run must confirm the read shape before this row is trusted.
|
|
33
|
+
ReportType: ["deployed"]
|
|
34
|
+
};
|
|
35
|
+
function coerceKnownBooleans(typeName, raw) {
|
|
36
|
+
if (!raw)
|
|
37
|
+
return raw;
|
|
38
|
+
const keys = GENERIC_BOOLEAN_KEYS[typeName];
|
|
39
|
+
if (!keys)
|
|
40
|
+
return raw;
|
|
41
|
+
const out = { ...raw };
|
|
42
|
+
for (const k of keys) {
|
|
43
|
+
if (out[k] === "true")
|
|
44
|
+
out[k] = true;
|
|
45
|
+
else if (out[k] === "false")
|
|
46
|
+
out[k] = false;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
export async function readGenericMetadataSnapshot(org, family, typeName, fullName) {
|
|
51
|
+
// See customFieldSnapshot: an empty reader response is not-found, not a crash.
|
|
52
|
+
const rows = await org.metadataRead(typeName, [fullName]);
|
|
53
|
+
const result = rows[0] ?? { typeName, fullName, found: false, errors: ["metadataRead returned no rows"] };
|
|
54
|
+
const raw = result.found ? result.metadata ?? null : null;
|
|
55
|
+
// F11: CustomField-backed generic families (picklistDependency / formula / rollupSummary /
|
|
56
|
+
// lookupRelationship / masterDetailRelationship) must get the same valueSet normalization as the
|
|
57
|
+
// dedicated customField snapshot, or a correct raw value set false-fails on
|
|
58
|
+
// `$.metadata.valueSet.values`.
|
|
59
|
+
const metadata = typeName === "CustomField" ? normalizeCustomFieldMetadata(raw) : coerceKnownBooleans(typeName, raw);
|
|
60
|
+
return snapshot(family, typeName, fullName, "metadata.read", { metadata }, result.errors);
|
|
61
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Dedicated Layout snapshot.
|
|
2
|
+
//
|
|
3
|
+
// WHY LAYOUT NEEDS ITS OWN READER
|
|
4
|
+
//
|
|
5
|
+
// SOAP `readMetadata` OMITS related lists from the Layout it returns, and jsforce materializes the
|
|
6
|
+
// absent repeated element as `[]`. So a facet the platform declined to show is indistinguishable
|
|
7
|
+
// from a facet the layout genuinely does not have — and Layout is a MEASURED full-file-replace type
|
|
8
|
+
// (applyLoop PROVEN_REPLACE_TYPES: a 2-field file against a clone of a 99-field layout left ZERO of
|
|
9
|
+
// its 16 related lists). An author reading `relatedLists: []` off the snapshot writes a file that
|
|
10
|
+
// omits them, the deploy deletes them, and Salesforce reports success.
|
|
11
|
+
//
|
|
12
|
+
// LIVE-CAUGHT 2026-08-02, PV corpus Arm A, org 00Dfn00000BFjbBEAT: the projection recorded
|
|
13
|
+
// `metadata.relatedLists: []` for Account-Account Layout while the layout carried
|
|
14
|
+
// TPI_Rail_Inspection__c.Account__c. The supervisor caught it by an independent read and restored
|
|
15
|
+
// it by hand. Nothing in the toolchain would have.
|
|
16
|
+
//
|
|
17
|
+
// MEASURED, same org, same day, all 436 layouts, readMetadata vs `sf project retrieve` (the
|
|
18
|
+
// deploy-symmetric source):
|
|
19
|
+
// - 12 layouts disagree; 56 related list items silently missing.
|
|
20
|
+
// - It is NOT all-or-nothing. Account-Account Layout - CSM read 17 of 20, Account-Account Layout
|
|
21
|
+
// MDO read 23 of 34, Opportunity-Simple Opportunity Layout read 18 of 26. A populated-looking
|
|
22
|
+
// array is no evidence of a complete one.
|
|
23
|
+
// - The omissions are not a namespace or an object class: standard lists (RelatedFileList,
|
|
24
|
+
// RelatedHistoryList, RelatedProcessHistoryList, RelatedContentNoteList,
|
|
25
|
+
// RelatedEntityHistoryList) were dropped too, and the same list type came back fine on other
|
|
26
|
+
// layouts. There is no predicate to filter on — which is exactly why the read cannot be trusted
|
|
27
|
+
// and a second source is the only honest answer.
|
|
28
|
+
//
|
|
29
|
+
// THE SECOND SOURCE, measured the same way: Tooling `SELECT Metadata FROM Layout` matched retrieve
|
|
30
|
+
// on 420/436 layouts with ZERO mismatches. The 16 that did not resolve were an artifact of the
|
|
31
|
+
// census addressing layouts by SDR filename (percent-encoded); by fullName only the three
|
|
32
|
+
// pseudo-object layouts (CaseClose, CaseInteraction, CommunityMemberLayout — no EntityDefinition)
|
|
33
|
+
// fail to resolve, and those fail CLOSED here.
|
|
34
|
+
//
|
|
35
|
+
// The precedent for a Tooling side-probe filling a Metadata API blind spot is already in this
|
|
36
|
+
// codebase: field history tracking is probed the same way on both sides of a deploy, because the
|
|
37
|
+
// Metadata API never returns the facet at all.
|
|
38
|
+
import { readGenericMetadataSnapshot } from "./genericSnapshot.js";
|
|
39
|
+
import { snapshot } from "./snapshotTypes.js";
|
|
40
|
+
/**
|
|
41
|
+
* Marker prefix for a facet this snapshot could NOT read completely.
|
|
42
|
+
*
|
|
43
|
+
* It rides the existing `errors: string[]` rather than adding a field, so every consumer that
|
|
44
|
+
* already treats a snapshot with errors as untrustworthy (the manifest error count, the direct
|
|
45
|
+
* tier's fail-closed checks) gets the new case for free, and the persisted snapshot schema does not
|
|
46
|
+
* move. `incompleteFacetsOf` is the reader.
|
|
47
|
+
*/
|
|
48
|
+
export const INCOMPLETE_FACET_PREFIX = "INCOMPLETE_FACET ";
|
|
49
|
+
/** The facet names an errors array marks as incompletely read. */
|
|
50
|
+
export function incompleteFacetsOf(errors) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const e of errors ?? []) {
|
|
53
|
+
if (!e.startsWith(INCOMPLETE_FACET_PREFIX))
|
|
54
|
+
continue;
|
|
55
|
+
const rest = e.slice(INCOMPLETE_FACET_PREFIX.length);
|
|
56
|
+
const facet = rest.slice(0, rest.indexOf(":") === -1 ? rest.length : rest.indexOf(":")).trim();
|
|
57
|
+
if (facet)
|
|
58
|
+
out.push(facet);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
export async function readLayoutSnapshot(org, fullName, rowCache) {
|
|
63
|
+
const base = await readGenericMetadataSnapshot(org, "layout", "Layout", fullName);
|
|
64
|
+
const metadata = base.state.metadata;
|
|
65
|
+
// A layout that does not exist has no facet to be incomplete about, and probing for one would
|
|
66
|
+
// turn a clean absence into a spurious error.
|
|
67
|
+
if (!metadata)
|
|
68
|
+
return base;
|
|
69
|
+
const errors = [...(base.errors ?? [])];
|
|
70
|
+
const probe = await readRelatedLists(org, fullName, rowCache);
|
|
71
|
+
if (probe.ok) {
|
|
72
|
+
return snapshot("layout", "Layout", fullName, "metadata.read + tooling:Layout.Metadata.relatedLists", { metadata: { ...metadata, relatedLists: probe.relatedLists } }, errors.length > 0 ? errors : undefined);
|
|
73
|
+
}
|
|
74
|
+
// DROP the value rather than leave a known-unreliable one in place. Keeping `[]` would let a
|
|
75
|
+
// reader who ignores the errors array author straight into the deletion this module exists to
|
|
76
|
+
// prevent; removing the key means the facet is visibly unanswered, which is the truth.
|
|
77
|
+
const { relatedLists: _unreliable, ...rest } = metadata;
|
|
78
|
+
errors.push(`${INCOMPLETE_FACET_PREFIX}relatedLists: ${probe.reason}. SOAP readMetadata omits Layout related lists without saying so, ` +
|
|
79
|
+
`so the value it returned is not evidence of anything and has been dropped from this snapshot. ` +
|
|
80
|
+
`Read the layout's related lists from retrieved source XML before authoring a replacement.`);
|
|
81
|
+
return snapshot("layout", "Layout", fullName, "metadata.read", { metadata: rest }, errors);
|
|
82
|
+
}
|
|
83
|
+
async function readRelatedLists(org, fullName, rowCache) {
|
|
84
|
+
const resolved = await resolveLayoutId(org, fullName, rowCache);
|
|
85
|
+
if (!resolved.ok)
|
|
86
|
+
return resolved;
|
|
87
|
+
try {
|
|
88
|
+
const res = await org.toolingQuery(`SELECT Metadata FROM Layout WHERE Id = '${resolved.id}'`);
|
|
89
|
+
const md = res.records[0]?.Metadata;
|
|
90
|
+
if (!md || typeof md !== "object")
|
|
91
|
+
return { ok: false, reason: `Tooling returned no Metadata for Layout ${resolved.id}` };
|
|
92
|
+
const rl = md.relatedLists;
|
|
93
|
+
// Null is the shape a layout with genuinely no related lists comes back as (observed on every
|
|
94
|
+
// such layout in the census); a single item collapses to a bare object, as everywhere else in
|
|
95
|
+
// this API.
|
|
96
|
+
return { ok: true, relatedLists: Array.isArray(rl) ? rl : rl === null || rl === undefined ? [] : [rl] };
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return { ok: false, reason: `Tooling Metadata read failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `<Object>-<Label>` to exactly one Tooling Layout Id, or a refusal.
|
|
104
|
+
*
|
|
105
|
+
* Matching happens in JS against every layout row for the object rather than in a WHERE clause,
|
|
106
|
+
* for two reasons: labels carry quotes, ampersands, em dashes and colons that would need SOQL
|
|
107
|
+
* escaping, and `TableEnumOrId` holds an object Id for custom objects but an API name for standard
|
|
108
|
+
* ones. `EntityDefinition.QualifiedApiName` addresses both uniformly.
|
|
109
|
+
*
|
|
110
|
+
* Ambiguity FAILS rather than picks. Three layouts on Account answer to "Account Layout" in the
|
|
111
|
+
* reference org (two managed, one ours); returning a managed package's related lists for our layout
|
|
112
|
+
* would be a worse lie than returning none.
|
|
113
|
+
*/
|
|
114
|
+
async function resolveLayoutId(org, fullName, rowCache) {
|
|
115
|
+
const dash = fullName.indexOf("-");
|
|
116
|
+
if (dash <= 0 || dash === fullName.length - 1) {
|
|
117
|
+
return { ok: false, reason: `"${fullName}" is not <Object>-<Label>, so the layout cannot be addressed` };
|
|
118
|
+
}
|
|
119
|
+
const object = fullName.slice(0, dash);
|
|
120
|
+
const label = fullName.slice(dash + 1);
|
|
121
|
+
if (!/^[A-Za-z0-9_]+$/.test(object)) {
|
|
122
|
+
return { ok: false, reason: `"${object}" is not an object API name` };
|
|
123
|
+
}
|
|
124
|
+
if (typeof org.toolingQuery !== "function") {
|
|
125
|
+
return { ok: false, reason: "this reader cannot run Tooling queries" };
|
|
126
|
+
}
|
|
127
|
+
let rows = rowCache?.get(object);
|
|
128
|
+
if (!rows) {
|
|
129
|
+
try {
|
|
130
|
+
const res = await org.toolingQuery(`SELECT Id, Name, NamespacePrefix FROM Layout WHERE EntityDefinition.QualifiedApiName = '${object}'`);
|
|
131
|
+
rows = res.records;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
// Not cached: a failure is this call's answer, not a fact about the object, and caching it
|
|
135
|
+
// would turn one transient error into a whole run's worth of INCOMPLETE facets.
|
|
136
|
+
return { ok: false, reason: `Tooling layout enumeration for ${object} failed: ${error instanceof Error ? error.message : String(error)}` };
|
|
137
|
+
}
|
|
138
|
+
rowCache?.set(object, rows);
|
|
139
|
+
}
|
|
140
|
+
// A managed layout's fullName carries its namespace in the LABEL segment (Account-ffbc__Account
|
|
141
|
+
// Layout), which is exactly how the row reconstructs.
|
|
142
|
+
const matches = rows.filter((r) => `${r.NamespacePrefix ? `${r.NamespacePrefix}__` : ""}${r.Name}` === label);
|
|
143
|
+
const id = matches.length === 1 ? matches[0]?.Id : undefined;
|
|
144
|
+
if (!id) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: `${matches.length} of the ${rows.length} Tooling Layout row(s) on ${object} answer to "${label}"`
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return { ok: true, id };
|
|
151
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Live org reader — the read half of the direct tier.
|
|
2
|
+
//
|
|
3
|
+
// Replaces sf-config-verifier's SalesforceOrgReader, which implemented eight methods against the
|
|
4
|
+
// full verifier OrgReader interface. cc-mcp calls three of them: identity() once, toolingQuery()
|
|
5
|
+
// five times (scope enumeration), and metadataRead() via the snapshot readers.
|
|
6
|
+
//
|
|
7
|
+
// The five that are gone were not merely unused — metadataRetrieve() was the only reason
|
|
8
|
+
// ComponentSet, RegistryAccess and SDR's metadataRegistry.json were pulled onto this path, and
|
|
9
|
+
// runApex()/soqlQuery()/describeObject() are write-adjacent or persona-scoped surfaces the direct
|
|
10
|
+
// tier deliberately does not have. describe_object is served by the read-only sf CLI passthrough
|
|
11
|
+
// instead, so nothing regressed by dropping it here.
|
|
12
|
+
//
|
|
13
|
+
// Auth rides the sf CLI's stored auth (Org.create by alias). No tokens pass through this process.
|
|
14
|
+
import { meteredApiCall } from "../core/orgCallMeter.js";
|
|
15
|
+
// The Metadata API's readMetadata() accepts up to TEN fullNames per call. Every snapshot reader
|
|
16
|
+
// asks for exactly one, which is correct but was costing one HTTP round-trip per component: a
|
|
17
|
+
// projection scoped to Account + Contact resolved to ~490 custom fields and therefore ~490
|
|
18
|
+
// sequential SOAP calls, which ran for over five minutes and looked indistinguishable from a hang.
|
|
19
|
+
const READ_BATCH = 10;
|
|
20
|
+
// Concurrent in-flight batches. Deliberately modest: the point is to stop paying full latency 490
|
|
21
|
+
// times over, not to lean on the org. Salesforce applies concurrent-request limits per org, and a
|
|
22
|
+
// projection is a read that runs alongside whatever else the consultant's org is doing.
|
|
23
|
+
const READ_CONCURRENCY = 5;
|
|
24
|
+
export class SalesforceOrgReader {
|
|
25
|
+
opts;
|
|
26
|
+
org;
|
|
27
|
+
conn;
|
|
28
|
+
// Populated by prefetch(); drained by metadataRead(). Keyed `typeName:fullName`.
|
|
29
|
+
//
|
|
30
|
+
// This said "constructed per projection run, so there is no staleness window to reason about".
|
|
31
|
+
// That was FALSE and it is what made the bug: direct_apply reuses one reader across a
|
|
32
|
+
// pre-projection, a deploy, and a post-projection. The staleness window was the deploy itself —
|
|
33
|
+
// the most important moment in the run. runProjection now calls invalidate() on entry, so the
|
|
34
|
+
// lifetime really is one projection.
|
|
35
|
+
cache = new Map();
|
|
36
|
+
constructor(opts) {
|
|
37
|
+
this.opts = opts;
|
|
38
|
+
}
|
|
39
|
+
async connection() {
|
|
40
|
+
if (!this.conn) {
|
|
41
|
+
// METERED as an org call, because it is one: `Org.create` reads the auth file, refreshes the
|
|
42
|
+
// access token if it is stale, and pulls in @salesforce/core's whole dependency chain. On a
|
|
43
|
+
// slow machine that is the single most expensive moment of a wave, and folding it silently
|
|
44
|
+
// into whichever read happened to be first would misattribute startup cost to a metadata
|
|
45
|
+
// read. See core/orgCallMeter.ts on why first-call cost is tracked separately at all.
|
|
46
|
+
//
|
|
47
|
+
// Imported lazily: @salesforce/core is heavy and only a live read needs it, so tests and
|
|
48
|
+
// server startup never pay for (or break on) that dependency chain.
|
|
49
|
+
await meteredApiCall(async () => {
|
|
50
|
+
const { Org } = await import("@salesforce/core");
|
|
51
|
+
if (!this.org)
|
|
52
|
+
this.org = await Org.create({ aliasOrUsername: this.opts.alias });
|
|
53
|
+
this.conn = this.org.getConnection(this.opts.apiVersion);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return this.conn;
|
|
57
|
+
}
|
|
58
|
+
async identity() {
|
|
59
|
+
const conn = await this.connection();
|
|
60
|
+
const orgId = typeof conn.getAuthInfoFields === "function" ? conn.getAuthInfoFields().orgId ?? "" : "";
|
|
61
|
+
const username = typeof conn.getUsername === "function" ? conn.getUsername() : this.opts.alias;
|
|
62
|
+
return { orgId, username, apiVersion: this.opts.apiVersion };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Drop everything cached. A projection is a POINT-IN-TIME read of the org, so anything held from
|
|
66
|
+
* an earlier one is a different point in time.
|
|
67
|
+
*
|
|
68
|
+
* Without this the cache was actively lying. direct_apply runs a pre-projection and a
|
|
69
|
+
* post-projection through the SAME reader: the pre-projection cached "not found" for every
|
|
70
|
+
* component the deploy was about to create, the deploy created them, and the post-projection then
|
|
71
|
+
* served those cached misses straight back. Every create read back absent, so nearly every wave
|
|
72
|
+
* returned DELIVERED_WITH_DRIFT on work that had landed perfectly.
|
|
73
|
+
*
|
|
74
|
+
* It also explains why waiting longer never helped: the retry re-ran the projection against the
|
|
75
|
+
* same poisoned cache. The "settle times" that appeared to justify a 47s backoff were the operator
|
|
76
|
+
* re-running project_org, which builds a FRESH reader — they were never measuring Salesforce.
|
|
77
|
+
*/
|
|
78
|
+
invalidate() {
|
|
79
|
+
this.cache.clear();
|
|
80
|
+
}
|
|
81
|
+
// Warm the cache for components the caller already knows it will ask for, batched and in parallel.
|
|
82
|
+
//
|
|
83
|
+
// Best-effort by design: anything this fails to fetch is simply absent from the cache, and
|
|
84
|
+
// metadataRead falls through to its normal single read. That keeps prefetch a pure optimization —
|
|
85
|
+
// it can never turn a projection that would have succeeded into one that fails.
|
|
86
|
+
//
|
|
87
|
+
// A poisoned component is the reason for the per-chunk fallback. Salesforce does not return
|
|
88
|
+
// found:false for orphaned metadata (a component whose parent object was removed by an
|
|
89
|
+
// uninstalled managed package); it throws a SOAP fault for the whole request. Batched naively,
|
|
90
|
+
// one such component would take out the nine good ones sharing its chunk and convert a
|
|
91
|
+
// recoverable per-component error into nine spurious ones. On a chunk failure we re-read its
|
|
92
|
+
// members individually so the blast radius stays exactly one component, which is the behaviour
|
|
93
|
+
// the caller's per-component try/catch was already written to expect.
|
|
94
|
+
async prefetch(requests) {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
for (const { typeName, fullNames } of requests) {
|
|
97
|
+
const pending = fullNames.filter((n) => !this.cache.has(`${typeName}:${n}`));
|
|
98
|
+
for (let i = 0; i < pending.length; i += READ_BATCH) {
|
|
99
|
+
chunks.push({ typeName, names: pending.slice(i, i + READ_BATCH) });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
let next = 0;
|
|
103
|
+
const worker = async () => {
|
|
104
|
+
for (;;) {
|
|
105
|
+
const chunk = chunks[next++];
|
|
106
|
+
if (!chunk)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
await this.fetchInto(chunk.typeName, chunk.names);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
for (const name of chunk.names) {
|
|
113
|
+
try {
|
|
114
|
+
await this.fetchInto(chunk.typeName, [name]);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Leave it uncached. metadataRead will retry it alone and the caller's existing
|
|
118
|
+
// error handling will record it as an unreadable component, with its real message.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
await Promise.all(Array.from({ length: Math.min(READ_CONCURRENCY, chunks.length) }, worker));
|
|
125
|
+
}
|
|
126
|
+
async fetchInto(typeName, fullNames) {
|
|
127
|
+
for (const row of await this.readUncached(typeName, fullNames)) {
|
|
128
|
+
this.cache.set(`${typeName}:${row.fullName}`, row);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async metadataRead(typeName, fullNames) {
|
|
132
|
+
const misses = fullNames.filter((n) => !this.cache.has(`${typeName}:${n}`));
|
|
133
|
+
if (misses.length > 0) {
|
|
134
|
+
// Chunked even here: a caller is free to pass more than the API's per-call limit, and
|
|
135
|
+
// silently truncating to the first ten would report real components as not-found.
|
|
136
|
+
for (let i = 0; i < misses.length; i += READ_BATCH) {
|
|
137
|
+
await this.fetchInto(typeName, misses.slice(i, i + READ_BATCH));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return fullNames.map((fullName) => this.cache.get(`${typeName}:${fullName}`) ?? {
|
|
141
|
+
typeName,
|
|
142
|
+
fullName,
|
|
143
|
+
found: false,
|
|
144
|
+
metadata: null,
|
|
145
|
+
errors: [`${typeName}:${fullName} not found`]
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
async readUncached(typeName, fullNames) {
|
|
149
|
+
const conn = await this.connection();
|
|
150
|
+
// ONE round-trip per call, whatever the chunk size — this is the unit the meter counts, and it
|
|
151
|
+
// is where a projection's time actually goes (tens of these per wave against three sf spawns).
|
|
152
|
+
const result = await meteredApiCall(() => conn.metadata.read(typeName, fullNames));
|
|
153
|
+
// The Metadata API collapses a single-element response to a bare object rather than an array —
|
|
154
|
+
// the same shape hazard the snapshot normalizers exist to absorb.
|
|
155
|
+
const rows = Array.isArray(result) ? result : [result];
|
|
156
|
+
return fullNames.map((fullName, i) => {
|
|
157
|
+
const metadata = rows[i];
|
|
158
|
+
// A read for a missing component still returns an object; absence of fullName is what marks
|
|
159
|
+
// it as not-found. Treated as not-found rather than an error, so callers can distinguish
|
|
160
|
+
// "does not exist" from "could not look".
|
|
161
|
+
const found = Boolean(metadata && typeof metadata === "object" && metadata.fullName !== undefined);
|
|
162
|
+
return {
|
|
163
|
+
typeName,
|
|
164
|
+
fullName,
|
|
165
|
+
found,
|
|
166
|
+
metadata: metadata ?? null,
|
|
167
|
+
errors: found ? undefined : [`${typeName}:${fullName} not found`]
|
|
168
|
+
};
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async toolingQuery(soql) {
|
|
172
|
+
const conn = await this.connection();
|
|
173
|
+
const result = await meteredApiCall(() => conn.tooling.query(soql));
|
|
174
|
+
return { records: (result.records ?? []) };
|
|
175
|
+
}
|
|
176
|
+
// Regular-API SOQL. Exists because the org-trait probes (CurrencyType, RecordType, CallCenter)
|
|
177
|
+
// and TabDefinition are NOT Tooling objects — routed through tooling.query they fail on every
|
|
178
|
+
// org and a probe failure is indistinguishable from "feature off".
|
|
179
|
+
async soqlQuery(soql) {
|
|
180
|
+
const conn = await this.connection();
|
|
181
|
+
const result = await meteredApiCall(() => conn.query(soql));
|
|
182
|
+
return { records: (result.records ?? []) };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Snapshot envelope for a single component read out of a live org.
|
|
2
|
+
//
|
|
3
|
+
// Ported from sf-config-verifier (src/snapshots/types.ts). Upstream typed `family` and `source`
|
|
4
|
+
// against @in2/contracts' FamilyKind / EvidenceSurface unions; here they are plain strings, because
|
|
5
|
+
// the direct tier does not consume the contract vocabulary and importing a 3,191-line schema
|
|
6
|
+
// package to narrow two string fields is the trade this port exists to undo.
|
|
7
|
+
export function snapshot(family, typeName, fullName, source, state, errors) {
|
|
8
|
+
return { family, typeName, fullName, source, state, errors, observedAt: new Date().toISOString() };
|
|
9
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The org read path. This used to bridge into sf-config-verifier; those modules now live in
|
|
2
|
+
// ../orgread as first-class source (see orgread/fieldMetadata.ts for what that code encodes and
|
|
3
|
+
// where each rule came from). The indirection is kept because call sites already import through it,
|
|
4
|
+
// and it remains the single place the live reader is constructed.
|
|
5
|
+
//
|
|
6
|
+
// The snapshot readers are pure (types + normalization only) — safe to load statically everywhere,
|
|
7
|
+
// including tests.
|
|
8
|
+
export { readCustomFieldSnapshot } from "../orgread/customFieldSnapshot.js";
|
|
9
|
+
export { readGenericMetadataSnapshot } from "../orgread/genericSnapshot.js";
|
|
10
|
+
export { readLayoutSnapshot, incompleteFacetsOf, INCOMPLETE_FACET_PREFIX } from "../orgread/layoutSnapshot.js";
|
|
11
|
+
export { normalizeCustomFieldMetadata, valueSetDefaultValue } from "../orgread/fieldMetadata.js";
|
|
12
|
+
export class OrgReaderUnavailableError extends Error {
|
|
13
|
+
}
|
|
14
|
+
export async function createLiveOrgReader(alias, apiVersion) {
|
|
15
|
+
// The reader itself is pure; only its @salesforce/core import is heavy, and that is deferred
|
|
16
|
+
// inside the class so tests and server startup never pay for (or break on) that dependency
|
|
17
|
+
// chain. Construction stays wrapped so a runtime that cannot load the Salesforce stack (e.g. a
|
|
18
|
+
// Node too old for undici) produces a clear refusal, never an INTERNAL_ERROR.
|
|
19
|
+
try {
|
|
20
|
+
const { SalesforceOrgReader } = await import("../orgread/salesforceOrgReader.js");
|
|
21
|
+
return new SalesforceOrgReader({ alias, apiVersion });
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
throw new OrgReaderUnavailableError(`live org reader failed to load (Node ${process.version}): ${error instanceof Error ? error.message : String(error)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
function show(value) {
|
|
4
|
+
const s = value === undefined ? "(absent)" : JSON.stringify(value);
|
|
5
|
+
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
6
|
+
}
|
|
7
|
+
function isPlainObject(v) {
|
|
8
|
+
return Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
9
|
+
}
|
|
10
|
+
// Object KEY ORDER is never semantic in Salesforce metadata: author XML yields one order,
|
|
11
|
+
// the org read-back another (6.5.1 run: GVS customValue {fullName,label,default} vs
|
|
12
|
+
// {fullName,default,label} — identical metadata, false-flagged as drift). Canonicalize keys
|
|
13
|
+
// before serializing so equality and the reorder (orderOnly) check compare by CONTENT.
|
|
14
|
+
export function sortKeysDeep(v) {
|
|
15
|
+
if (Array.isArray(v))
|
|
16
|
+
return v.map(sortKeysDeep);
|
|
17
|
+
if (v && typeof v === "object") {
|
|
18
|
+
const rec = v;
|
|
19
|
+
return Object.fromEntries(Object.keys(rec).sort().map((k) => [k, sortKeysDeep(rec[k])]));
|
|
20
|
+
}
|
|
21
|
+
return v;
|
|
22
|
+
}
|
|
23
|
+
export const canonJson = (v) => JSON.stringify(sortKeysDeep(v));
|
|
24
|
+
export function diffMetadata(before, after, at = "metadata") {
|
|
25
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
26
|
+
const changes = [];
|
|
27
|
+
for (const key of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
|
|
28
|
+
changes.push(...diffMetadata(before[key], after[key], `${at}.${key}`));
|
|
29
|
+
}
|
|
30
|
+
return changes;
|
|
31
|
+
}
|
|
32
|
+
const b = canonJson(before);
|
|
33
|
+
const a = canonJson(after);
|
|
34
|
+
if (b === a)
|
|
35
|
+
return [];
|
|
36
|
+
if (Array.isArray(before) && Array.isArray(after) && before.length === after.length) {
|
|
37
|
+
const multiset = (arr) => arr.map((v) => canonJson(v)).sort().join("\u0000");
|
|
38
|
+
if (multiset(before) === multiset(after)) {
|
|
39
|
+
return [{ path: at, before: show(before), after: show(after), orderOnly: true }];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return [{ path: at, before: show(before), after: show(after) }];
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Child collections that project as separate components (their own diffs cover them).
|
|
46
|
+
*
|
|
47
|
+
* One list, two consumers. This module excludes them from the PARENT's diff; the org mirror needs
|
|
48
|
+
* the same set from the other direction — a wave that delivers only children never refreshes the
|
|
49
|
+
* parent's mirrored file, so these are exactly the keys whose disagreement the mirror may be able to
|
|
50
|
+
* attribute to its own recorded history rather than to an outside edit (`orgRepo.computeDrift`).
|
|
51
|
+
*/
|
|
52
|
+
export const CHILD_COLLECTION_NAMES = [
|
|
53
|
+
"fields",
|
|
54
|
+
"listViews",
|
|
55
|
+
"compactLayouts",
|
|
56
|
+
"validationRules",
|
|
57
|
+
"recordTypes",
|
|
58
|
+
"webLinks",
|
|
59
|
+
"businessProcesses",
|
|
60
|
+
"fieldSets",
|
|
61
|
+
"indexes",
|
|
62
|
+
"sharingReasons"
|
|
63
|
+
];
|
|
64
|
+
const CHILD_COLLECTION_PATH_RE = new RegExp(`^metadata\\.(${CHILD_COLLECTION_NAMES.join("|")})\\b`);
|
|
65
|
+
/** Is this a CustomObject facet path that belongs to a child component's own diff? Shared with the
|
|
66
|
+
* unstated-change check, which compares the same embedded read-back and needs the same exclusion —
|
|
67
|
+
* otherwise a wave deploying an object plus its fields reports each field twice. */
|
|
68
|
+
export const isChildCollectionPath = (path) => CHILD_COLLECTION_PATH_RE.test(path);
|
|
69
|
+
async function loadManifest(dir) {
|
|
70
|
+
return JSON.parse(await fs.readFile(path.join(dir, "projection.json"), "utf8"));
|
|
71
|
+
}
|
|
72
|
+
async function loadSnapshot(dir, relFile) {
|
|
73
|
+
return JSON.parse(await fs.readFile(path.join(dir, relFile), "utf8"));
|
|
74
|
+
}
|
|
75
|
+
export async function diffProjections(baseDir, nextDir) {
|
|
76
|
+
const base = await loadManifest(baseDir);
|
|
77
|
+
const next = await loadManifest(nextDir);
|
|
78
|
+
const baseByKey = new Map(base.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
79
|
+
const nextByKey = new Map(next.components.map((c) => [`${c.typeName}:${c.fullName}`, c]));
|
|
80
|
+
const diff = {
|
|
81
|
+
base: `${base.label} (${baseDir})`,
|
|
82
|
+
next: `${next.label} (${nextDir})`,
|
|
83
|
+
added: [...nextByKey.keys()].filter((k) => !baseByKey.has(k)).sort(),
|
|
84
|
+
removed: [...baseByKey.keys()].filter((k) => !nextByKey.has(k)).sort(),
|
|
85
|
+
changed: []
|
|
86
|
+
};
|
|
87
|
+
for (const [key, nextEntry] of [...nextByKey.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
88
|
+
const baseEntry = baseByKey.get(key);
|
|
89
|
+
if (!baseEntry)
|
|
90
|
+
continue;
|
|
91
|
+
const baseSnap = await loadSnapshot(baseDir, baseEntry.file);
|
|
92
|
+
const nextSnap = await loadSnapshot(nextDir, nextEntry.file);
|
|
93
|
+
let changes = diffMetadata(baseSnap.state.metadata, nextSnap.state.metadata);
|
|
94
|
+
// Stress-caught (case B, 2026-07-22): the CustomObject read-back EMBEDS child
|
|
95
|
+
// collections (fields, listViews, ...) that the projection also captures as their
|
|
96
|
+
// own components — a child edit would double-count as a parent-object change and
|
|
97
|
+
// false-flag a side change. The parent diff covers parent-level facets only.
|
|
98
|
+
if (key.startsWith("CustomObject:")) {
|
|
99
|
+
changes = changes.filter((c) => !CHILD_COLLECTION_PATH_RE.test(c.path));
|
|
100
|
+
}
|
|
101
|
+
if (changes.length > 0)
|
|
102
|
+
diff.changed.push({ key, file: nextEntry.file, changes });
|
|
103
|
+
}
|
|
104
|
+
return diff;
|
|
105
|
+
}
|
|
106
|
+
export function renderDiff(diff) {
|
|
107
|
+
const lines = [];
|
|
108
|
+
lines.push("# Projection Diff");
|
|
109
|
+
lines.push("");
|
|
110
|
+
lines.push(`Base: ${diff.base}`);
|
|
111
|
+
lines.push(`Next: ${diff.next}`);
|
|
112
|
+
lines.push("");
|
|
113
|
+
if (diff.added.length === 0 && diff.removed.length === 0 && diff.changed.length === 0) {
|
|
114
|
+
lines.push("No differences — the projected slice is identical (envelope timestamps ignored).");
|
|
115
|
+
lines.push("");
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
if (diff.added.length) {
|
|
119
|
+
lines.push(`## Added (${diff.added.length})`);
|
|
120
|
+
for (const k of diff.added)
|
|
121
|
+
lines.push(`- ${k}`);
|
|
122
|
+
lines.push("");
|
|
123
|
+
}
|
|
124
|
+
if (diff.removed.length) {
|
|
125
|
+
lines.push(`## Removed (${diff.removed.length})`);
|
|
126
|
+
for (const k of diff.removed)
|
|
127
|
+
lines.push(`- ${k}`);
|
|
128
|
+
lines.push("");
|
|
129
|
+
}
|
|
130
|
+
if (diff.changed.length) {
|
|
131
|
+
lines.push(`## Changed (${diff.changed.length})`);
|
|
132
|
+
for (const c of diff.changed) {
|
|
133
|
+
lines.push(`### ${c.key} (${c.file})`);
|
|
134
|
+
for (const ch of c.changes) {
|
|
135
|
+
lines.push(`- \`${ch.path}\`: ${ch.before} → ${ch.after}`);
|
|
136
|
+
}
|
|
137
|
+
lines.push("");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return lines.join("\n");
|
|
141
|
+
}
|