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,1531 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { runProcess } from "../core/spawn.js";
|
|
4
|
+
import { ORGREPO_ROOT, WORKSPACE_ROOT } from "../core/paths.js";
|
|
5
|
+
import { CHILD_COLLECTION_NAMES, sortKeysDeep } from "../projection/diff.js";
|
|
6
|
+
import { expandComponent } from "./containers.js";
|
|
7
|
+
import { incompleteFacetsOf } from "../orgread/layoutSnapshot.js";
|
|
8
|
+
import { assessComparability, readerIdentityLabel } from "./snapshotComparability.js";
|
|
9
|
+
import { recordGitSpawn } from "./orgRepoSpawnCount.js";
|
|
10
|
+
// The per-org mirror (plan 2026-08-02-002 U2; origin R1–R6, R8).
|
|
11
|
+
//
|
|
12
|
+
// A git repository per org, holding the post-image of every component IN2 has ever TOUCHED there
|
|
13
|
+
// plus the source XML it deployed. Touched, not read: a wave's projection is deliberately wider
|
|
14
|
+
// than its work, and what separates the two is the wave's own request — see `isTouched`. It is a
|
|
15
|
+
// CACHE, not an authority: everything in it is derivable from the evidence bundles, and `rebuild`
|
|
16
|
+
// re-derives it. That is what makes it safe to throw away and what makes cross-machine convergence
|
|
17
|
+
// possible — two consultants holding the same bundle set produce the same history, byte for byte,
|
|
18
|
+
// without merging anything.
|
|
19
|
+
//
|
|
20
|
+
// Determinism is the whole design, so every input git would normally take from the environment is
|
|
21
|
+
// pinned:
|
|
22
|
+
//
|
|
23
|
+
// author/committer fixed constants (the operator rides a TRAILER, where it cannot move a SHA)
|
|
24
|
+
// timestamps the bundle's mutation window, never the wall clock
|
|
25
|
+
// ordering a total sort key — (window start, session id, wave, attempt) — never arrival
|
|
26
|
+
// order. ATTEMPT IS A TERM, not a detail: a retry is its own wave here (U2's
|
|
27
|
+
// KTD — amending would rewrite history), so it both separates two states of
|
|
28
|
+
// one wave in `waveIdentity` and orders them when their windows agree.
|
|
29
|
+
// config repo-local, with the user's global and system config neutralized outright
|
|
30
|
+
// line endings `* -text` plus core.autocrlf=false: no attribute may rewrite a byte
|
|
31
|
+
// object format SHA-1, stated rather than inherited from whatever the local git defaults to
|
|
32
|
+
//
|
|
33
|
+
// The remaining nondeterminism is the SERIALIZATION itself, and it is versioned rather than
|
|
34
|
+
// pretended away: every commit carries `Materializer:`, and a rebuild that meets a foreign trailer
|
|
35
|
+
// from a different version rebuilds the whole history rather than appending a differently-shaped
|
|
36
|
+
// commit onto it (U8 consumes this; the trailer is written here).
|
|
37
|
+
// v4 (2026-08-03): a facet the reader COULD NOT READ is now recorded, where before it vanished.
|
|
38
|
+
// `readLayoutSnapshot` drops `relatedLists` when its Tooling probe fails and declares
|
|
39
|
+
// `INCOMPLETE_FACET relatedLists: ...` in `errors` — and `canonicalSnapshot` stripped `errors`, so
|
|
40
|
+
// the mirror filed a layout that reads as simply having no related lists. `direct_history show`
|
|
41
|
+
// and `diff` then presented a false record of org state: an absence that looks like a fact.
|
|
42
|
+
// `incompleteFacets` now rides the committed snapshot, and an `Incomplete-Facet:` trailer rides the
|
|
43
|
+
// commit, so the bound is visible without reading blobs.
|
|
44
|
+
//
|
|
45
|
+
// The FACET NAME is persisted, never the reason text — see `canonicalSnapshot` for why that line is
|
|
46
|
+
// where it is.
|
|
47
|
+
//
|
|
48
|
+
// CHEAP ON EXISTING HISTORY, and measured rather than assumed: the key is emitted only when
|
|
49
|
+
// non-empty, so a snapshot with nothing to declare is byte-identical to its v3 form. Across the
|
|
50
|
+
// live corpus at the time of this change (org 00Dfn00000BFjbBEAT, 16 sessions, 1224 post-image
|
|
51
|
+
// component snapshots, 55 of them Layouts) ZERO carry an `INCOMPLETE_FACET` marker — 51 layouts
|
|
52
|
+
// predate the dedicated reader and 4 were read with the probe UP. Replaying all 68 bundles under v3
|
|
53
|
+
// and under v4 yields the IDENTICAL `projection/` subtree (e079a329): no blob changes content, so
|
|
54
|
+
// this bump rides along with v3's for free on any mirror not yet rebuilt. The one file that does
|
|
55
|
+
// move is the mirror's own README, which v4 rewrites — it promised "as the org reported it" with no
|
|
56
|
+
// caveat that the org sometimes does not report, and a doc that overstates a guarantee is a defect.
|
|
57
|
+
//
|
|
58
|
+
// v3 (2026-08-03): a disagreement between two snapshots read by DIFFERENT READERS is no longer
|
|
59
|
+
// carded as drift — it is recorded as `Drift-Not-Comparable:` instead (see
|
|
60
|
+
// `snapshotComparability.ts`). The TREE is untouched; what moves is the commit message, and on this
|
|
61
|
+
// history that is the same thing: a differently-worded commit is a different SHA, so v2 and v3
|
|
62
|
+
// replaying one bundle set diverge from the first affected wave onward. Stamped rather than slipped
|
|
63
|
+
// in, for the reason the v1->v2 note gives.
|
|
64
|
+
//
|
|
65
|
+
// Unlike most bumps this one REPAIRS recorded history rather than only re-deriving it. A v2 mirror
|
|
66
|
+
// holds false accusations: replaying the PV corpus (68 commits, org 00Dfn00000BFjbBEAT) under v3
|
|
67
|
+
// drops `Drift: Layout:PV_Asset__c-PV Asset Layout` — which was reader shape, not an edit — and
|
|
68
|
+
// leaves all six genuine `Drift: CustomObject:*` cards exactly where they were. Mirrors still on v2
|
|
69
|
+
// keep the false card until they are replayed: `in2 rebuild-mirror <orgId>`.
|
|
70
|
+
//
|
|
71
|
+
// v2 (2026-08-02): the overlay covers what a wave TOUCHED rather than everything its projection
|
|
72
|
+
// read — see `isTouched`. That changes the tree a given bundle materializes to, which is exactly
|
|
73
|
+
// what this stamp is for: an install still on v1 and an install on v2 replaying the same bundles
|
|
74
|
+
// produce different histories, and the trailer is what makes that visible rather than a mystery
|
|
75
|
+
// about why two consultants' SHAs disagree. Mirrors written by v1 keep their contaminated content
|
|
76
|
+
// until they are replayed — `in2 rebuild-mirror <orgId>`.
|
|
77
|
+
export const MATERIALIZER_VERSION = "orgrepo-v4";
|
|
78
|
+
// Fixed identity. Not a person — the person is `Operator:`. See the note above.
|
|
79
|
+
const AUTHOR_NAME = "IN2 Mirror";
|
|
80
|
+
const AUTHOR_EMAIL = "mirror@in2.local";
|
|
81
|
+
// The root commit's date. Any value works as long as it is the SAME value everywhere; the wall
|
|
82
|
+
// clock is the one value that is not.
|
|
83
|
+
const EPOCH = "1970-01-01T00:00:00Z";
|
|
84
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
85
|
+
const LOCK_POLL_MS = 25;
|
|
86
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
87
|
+
// A lock directory older than this belongs to a process that died holding it. Generous, because
|
|
88
|
+
// taking a live lock away is far worse than waiting: an apply's whole commit runs inside it.
|
|
89
|
+
const LOCK_STALE_MS = 5 * 60_000;
|
|
90
|
+
/** Verdicts that changed the org, and therefore the only ones that materialize (origin R2). */
|
|
91
|
+
const STATE_CHANGING = new Set(["DIFF_VERIFIED", "DELIVERED_WITH_DRIFT", "AMBIGUOUS_DELIVERY"]);
|
|
92
|
+
export const isStateChanging = (verdict) => STATE_CHANGING.has(verdict);
|
|
93
|
+
export class OrgRepoError extends Error {
|
|
94
|
+
code;
|
|
95
|
+
constructor(code, message) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.code = code;
|
|
98
|
+
this.name = "OrgRepoError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// ---------------------------------------------------------------------------------------------
|
|
102
|
+
// identity and path safety
|
|
103
|
+
// ---------------------------------------------------------------------------------------------
|
|
104
|
+
const ORG_ID_RE = /^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$/;
|
|
105
|
+
function assertOrgId(orgId) {
|
|
106
|
+
if (!ORG_ID_RE.test(orgId)) {
|
|
107
|
+
throw new OrgRepoError("INVALID_ORG_ID", `${JSON.stringify(orgId)} is not a Salesforce org id (15 or 18 alphanumerics). The mirror is keyed by org id and the key becomes a directory name, so anything else is refused rather than normalized.`);
|
|
108
|
+
}
|
|
109
|
+
return orgId;
|
|
110
|
+
}
|
|
111
|
+
export function mirrorPath(orgId, root) {
|
|
112
|
+
return path.join(root ?? ORGREPO_ROOT, assertOrgId(orgId));
|
|
113
|
+
}
|
|
114
|
+
/** A repo-relative path that cannot escape the repo. Manifest paths are org-derived — check them. */
|
|
115
|
+
function safeRelPath(rel) {
|
|
116
|
+
const normalized = path.normalize(rel).replace(/^[/\\]+/, "");
|
|
117
|
+
if (!normalized || normalized === "." || normalized.split(/[/\\]/).includes("..") || path.isAbsolute(normalized)) {
|
|
118
|
+
throw new OrgRepoError("UNSAFE_PATH", `refusing a mirror path that escapes the repository: ${JSON.stringify(rel)}`);
|
|
119
|
+
}
|
|
120
|
+
return normalized;
|
|
121
|
+
}
|
|
122
|
+
// A ref the caller may name. Not a git shell: anything option-shaped is refused outright rather
|
|
123
|
+
// than passed to git, where `--upload-pack=` and friends turn a read tool into an exec surface.
|
|
124
|
+
const REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/^~-]{0,199}$/;
|
|
125
|
+
function assertRef(ref) {
|
|
126
|
+
if (!REF_RE.test(ref) || ref.includes("..")) {
|
|
127
|
+
throw new OrgRepoError("INVALID_REF", `${JSON.stringify(ref)} is not a plain revision.`);
|
|
128
|
+
}
|
|
129
|
+
return ref;
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------------------------------------
|
|
132
|
+
// git plumbing
|
|
133
|
+
// ---------------------------------------------------------------------------------------------
|
|
134
|
+
// An always-empty config file, handed to git as its global and system config, so the user's
|
|
135
|
+
// ~/.gitconfig cannot reach in — which matters because commit.gpgsign, core.autocrlf and user.name
|
|
136
|
+
// are all things a developer's machine sets and all things that would make this machine's history
|
|
137
|
+
// disagree with everyone else's.
|
|
138
|
+
//
|
|
139
|
+
// `/dev/null` rather than a nonexistent path under the temp directory, which is what this was
|
|
140
|
+
// first: git treats a missing file as empty too, but on a shared machine `/tmp` is world-writable,
|
|
141
|
+
// so a fixed filename there is a config file ANYONE can create — and git config has entries
|
|
142
|
+
// (`core.fsmonitor`, `core.pager`, alias expansion) that name a program to run. `/dev/null` cannot
|
|
143
|
+
// be hijacked and always exists.
|
|
144
|
+
const NO_CONFIG = "/dev/null";
|
|
145
|
+
function gitEnv(date) {
|
|
146
|
+
const stamp = date ?? EPOCH;
|
|
147
|
+
return {
|
|
148
|
+
...process.env,
|
|
149
|
+
GIT_CONFIG_GLOBAL: NO_CONFIG,
|
|
150
|
+
GIT_CONFIG_SYSTEM: NO_CONFIG,
|
|
151
|
+
GIT_ATTR_NOSYSTEM: "1",
|
|
152
|
+
GIT_AUTHOR_NAME: AUTHOR_NAME,
|
|
153
|
+
GIT_AUTHOR_EMAIL: AUTHOR_EMAIL,
|
|
154
|
+
GIT_AUTHOR_DATE: stamp,
|
|
155
|
+
GIT_COMMITTER_NAME: AUTHOR_NAME,
|
|
156
|
+
GIT_COMMITTER_EMAIL: AUTHOR_EMAIL,
|
|
157
|
+
GIT_COMMITTER_DATE: stamp,
|
|
158
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
159
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
160
|
+
TZ: "UTC"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function git(cwd, args, opts = {}) {
|
|
164
|
+
recordGitSpawn();
|
|
165
|
+
const run = await runProcess("git", args, {
|
|
166
|
+
cwd,
|
|
167
|
+
env: gitEnv(opts.date),
|
|
168
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
169
|
+
...(opts.input !== undefined ? { input: opts.input } : {})
|
|
170
|
+
});
|
|
171
|
+
if (run.spawnError) {
|
|
172
|
+
throw new OrgRepoError("GIT_FAILED", `git is not available on PATH (${run.spawnError}) — the org mirror needs it. History for this org is not being recorded.`);
|
|
173
|
+
}
|
|
174
|
+
return { ok: run.exitCode === 0 && !run.timedOut, stdout: run.stdout, stderr: run.stderr, exitCode: run.exitCode };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* `git`, but keeping stdout's BYTES.
|
|
178
|
+
*
|
|
179
|
+
* Only `git cat-file --batch` needs this: its records are length-prefixed in bytes, so slicing a
|
|
180
|
+
* decoded string would drift the moment a snapshot carries a multi-byte character in a label — and
|
|
181
|
+
* drift there means returning one component's identity for another's, silently, in a report that
|
|
182
|
+
* names somebody's work.
|
|
183
|
+
*/
|
|
184
|
+
async function gitRaw(cwd, args, opts = {}) {
|
|
185
|
+
recordGitSpawn();
|
|
186
|
+
const run = await runProcess("git", args, {
|
|
187
|
+
cwd,
|
|
188
|
+
env: gitEnv(),
|
|
189
|
+
timeoutMs: GIT_TIMEOUT_MS,
|
|
190
|
+
binary: true,
|
|
191
|
+
...(opts.input !== undefined ? { input: opts.input } : {})
|
|
192
|
+
});
|
|
193
|
+
if (run.spawnError) {
|
|
194
|
+
throw new OrgRepoError("GIT_FAILED", `git is not available on PATH (${run.spawnError}) — the org mirror needs it. History for this org is not being recorded.`);
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
ok: run.exitCode === 0 && !run.timedOut,
|
|
198
|
+
stdout: run.stdout,
|
|
199
|
+
stderr: run.stderr,
|
|
200
|
+
exitCode: run.exitCode,
|
|
201
|
+
stdoutBuffer: run.stdoutBuffer ?? Buffer.alloc(0)
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
async function gitOrThrow(cwd, args, opts = {}) {
|
|
205
|
+
const run = await git(cwd, args, opts);
|
|
206
|
+
if (!run.ok) {
|
|
207
|
+
throw new OrgRepoError("GIT_FAILED", `git ${args[0]} failed (${run.exitCode}): ${(run.stderr || run.stdout).trim().slice(0, 400)}`);
|
|
208
|
+
}
|
|
209
|
+
return run.stdout;
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------------------------
|
|
212
|
+
// the per-org advisory lock
|
|
213
|
+
// ---------------------------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Serialize same-org work on an advisory `mkdir` lock — the pattern proven in the scale run.
|
|
216
|
+
*
|
|
217
|
+
* QUEUE, never refuse: parallel arms against one org are a supported scenario (origin R2), and a
|
|
218
|
+
* refusal there would make the mirror's completeness depend on scheduling luck. `mkdir` is the
|
|
219
|
+
* primitive because it is atomic on every filesystem this runs on, needs no daemon, and leaves a
|
|
220
|
+
* directory whose mtime is enough to spot a holder that died.
|
|
221
|
+
*/
|
|
222
|
+
export async function withOrgLock(orgId, opts, fn) {
|
|
223
|
+
const root = opts.root ?? ORGREPO_ROOT;
|
|
224
|
+
await fs.mkdir(root, { recursive: true });
|
|
225
|
+
const lockDir = path.join(root, `${assertOrgId(orgId)}.lock`);
|
|
226
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
227
|
+
for (;;) {
|
|
228
|
+
try {
|
|
229
|
+
await fs.mkdir(lockDir);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if (error.code !== "EEXIST")
|
|
234
|
+
throw error;
|
|
235
|
+
const age = await fs
|
|
236
|
+
.stat(lockDir)
|
|
237
|
+
.then((s) => Date.now() - s.mtimeMs)
|
|
238
|
+
.catch(() => 0);
|
|
239
|
+
if (age > LOCK_STALE_MS) {
|
|
240
|
+
// The holder died. Removing a live lock would corrupt an index mid-commit, which is why
|
|
241
|
+
// the staleness bar is minutes rather than seconds.
|
|
242
|
+
await fs.rm(lockDir, { recursive: true, force: true });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (Date.now() > deadline) {
|
|
246
|
+
throw new OrgRepoError("LOCK_TIMEOUT", `another apply has held the ${orgId} mirror lock for over ${Math.round(LOCK_TIMEOUT_MS / 1000)}s (${lockDir}).`);
|
|
247
|
+
}
|
|
248
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await fs
|
|
253
|
+
.writeFile(path.join(lockDir, "holder.json"), `${JSON.stringify({ pid: process.pid })}\n`, "utf8")
|
|
254
|
+
.catch(() => undefined);
|
|
255
|
+
return await fn();
|
|
256
|
+
}
|
|
257
|
+
finally {
|
|
258
|
+
await fs.rm(lockDir, { recursive: true, force: true }).catch(() => undefined);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------------------------
|
|
262
|
+
// materialization
|
|
263
|
+
// ---------------------------------------------------------------------------------------------
|
|
264
|
+
/**
|
|
265
|
+
* The bytes a component contributes to the mirror.
|
|
266
|
+
*
|
|
267
|
+
* `observedAt` is dropped, and this is the load-bearing line of the whole module: the projection
|
|
268
|
+
* stamps every snapshot with the moment it was read, so carrying it through would make an
|
|
269
|
+
* unchanged component look changed on every single wave — a commit per apply forever, and "no
|
|
270
|
+
* commit when nothing changed" (R2) quietly false. `errors` goes for the same reason from the other
|
|
271
|
+
* direction: it describes the READ, not the org.
|
|
272
|
+
*
|
|
273
|
+
* WITH ONE EXCEPTION, and the distinction is the whole of v4. An `INCOMPLETE_FACET` entry is not a
|
|
274
|
+
* fact about the read, it is a fact about THIS DOCUMENT: "the facet named here is not in the state
|
|
275
|
+
* below". `readLayoutSnapshot` DELETES the key it could not verify, so without the marker the
|
|
276
|
+
* snapshot asserts a clean absence — a layout with no related lists, indistinguishable from one
|
|
277
|
+
* whose related lists were never read. That is a false record of org state, and it is the one thing
|
|
278
|
+
* this tree must never contain. Every other `errors` entry ("read failed: Entity 'null' not found",
|
|
279
|
+
* a timeout) describes an attempt and is dropped exactly as before.
|
|
280
|
+
*
|
|
281
|
+
* THE FACET NAME IS PERSISTED, NOT THE MESSAGE. Two degraded reads of one layout carry different
|
|
282
|
+
* causes — "connection reset", "socket hang up" — while declaring the identical durable fact. The
|
|
283
|
+
* prose is transient read-error noise; storing it would make two byte-different blobs out of one
|
|
284
|
+
* org state, and a byte difference in this tree IS a drift card. Worse, `differingKeys` walks
|
|
285
|
+
* `state.metadata` and would not see a top-level errors difference at all, so the resulting card
|
|
286
|
+
* would name no facet and `assessComparability` would pass it through as COMPARABLE. A drift
|
|
287
|
+
* accusation manufactured entirely out of error text is precisely the failure #26 exists to stop.
|
|
288
|
+
*
|
|
289
|
+
* Top level, beside `family` and `source`, and NOT inside `state.metadata`: `differingKeys` reads
|
|
290
|
+
* the top of `metadata` as the org's facets, so a marker filed there would read as one.
|
|
291
|
+
*
|
|
292
|
+
* Keys are sorted deep, because Salesforce returns the same metadata in different key orders on
|
|
293
|
+
* different reads (the projection diff has carried this normalization since the 6.5.1 run).
|
|
294
|
+
*/
|
|
295
|
+
function canonicalSnapshot(snapshot) {
|
|
296
|
+
const source = (snapshot ?? {});
|
|
297
|
+
const { observedAt: _observedAt, errors, ...rest } = source;
|
|
298
|
+
const incomplete = incompleteFacetsOf(Array.isArray(errors) ? errors : undefined);
|
|
299
|
+
// Only when non-empty. A snapshot with nothing to declare must serialize byte-for-byte as it did
|
|
300
|
+
// before this key existed, so the bump costs nothing on history that has no gap to record.
|
|
301
|
+
const declared = [...new Set(incomplete)].sort();
|
|
302
|
+
const withMarker = declared.length > 0 ? { ...rest, incompleteFacets: declared } : rest;
|
|
303
|
+
return `${JSON.stringify(sortKeysDeep(withMarker), null, 2)}\n`;
|
|
304
|
+
}
|
|
305
|
+
const componentKey = (c) => `${c.typeName}:${c.fullName}`;
|
|
306
|
+
/**
|
|
307
|
+
* Trailer values are IDENTIFIERS, so the audit trail cannot ingest unsanitized org content.
|
|
308
|
+
*
|
|
309
|
+
* Component identities are org content — an admin names the layout — and they are recorded because
|
|
310
|
+
* identity is the entire point. What must not survive is the trailer GRAMMAR: a component named
|
|
311
|
+
* "x\nOrg-Id: 00Dhacked" would otherwise add a line that git parses as a trailer and rewrite the
|
|
312
|
+
* commit's own claim about which org it belongs to. Control characters go, whitespace collapses,
|
|
313
|
+
* and the value is capped — so an identity stays one value on one line.
|
|
314
|
+
*/
|
|
315
|
+
function trailerValue(raw) {
|
|
316
|
+
// eslint-disable-next-line no-control-regex
|
|
317
|
+
const flat = raw
|
|
318
|
+
.replace(/[\u0000-\u001f\u007f]+/g, " ")
|
|
319
|
+
.replace(/\s+/g, " ")
|
|
320
|
+
.trim();
|
|
321
|
+
return flat.slice(0, 200);
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* The differing facets, and an HONEST COUNT when there are more than fit.
|
|
325
|
+
*
|
|
326
|
+
* `trailerValue` caps a value at 200 characters, which on a long list would silently drop the tail —
|
|
327
|
+
* and this particular list is the evidence that a difference the reader change cannot explain is
|
|
328
|
+
* present. A truncation that reads as a complete list is the one failure this whole module exists to
|
|
329
|
+
* avoid, so the overflow is counted out loud. The full list always survives on the wave result and
|
|
330
|
+
* in `verdict.json`; only the git trailer is bounded.
|
|
331
|
+
*/
|
|
332
|
+
function facetList(keys, max = 12) {
|
|
333
|
+
const shown = keys.slice(0, max);
|
|
334
|
+
const rest = keys.length - shown.length;
|
|
335
|
+
return `${trailerValue(shown.join(", "))}${rest > 0 ? ` (+${rest} more)` : ""}`;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* The components this wave committed with a facet they could not read, as trailer values.
|
|
339
|
+
*
|
|
340
|
+
* Derived from the same `errors` arrays `canonicalSnapshot` reads, so the commit and the tree can
|
|
341
|
+
* never disagree about which facets went unread. It rides a trailer as well as the blob because
|
|
342
|
+
* every consumer that renders history — `direct_history log`/`show`, the batch report — reads
|
|
343
|
+
* trailers, and making them open blobs to find out would be a git call per file on a read path
|
|
344
|
+
* that currently makes none.
|
|
345
|
+
*/
|
|
346
|
+
function incompleteFacetTrailers(components) {
|
|
347
|
+
const out = [];
|
|
348
|
+
for (const component of components) {
|
|
349
|
+
const errors = component.snapshot?.errors;
|
|
350
|
+
const facets = [...new Set(incompleteFacetsOf(Array.isArray(errors) ? errors : undefined))].sort();
|
|
351
|
+
if (facets.length === 0)
|
|
352
|
+
continue;
|
|
353
|
+
out.push(`${trailerValue(componentKey(component))} <> ${facetList(facets)}`);
|
|
354
|
+
}
|
|
355
|
+
return out.sort();
|
|
356
|
+
}
|
|
357
|
+
function commitMessage(wave, changed, removed, drifted, attributed, incomparable) {
|
|
358
|
+
const subject = `${trailerValue(wave.wave)} attempt ${wave.attempt}: ${trailerValue(wave.verdict)} — ` +
|
|
359
|
+
`${changed} component(s)${removed ? `, ${removed} removed` : ""}`;
|
|
360
|
+
const trailers = [
|
|
361
|
+
`Org-Id: ${wave.orgId}`,
|
|
362
|
+
`Session-Id: ${trailerValue(wave.sessionId)}`,
|
|
363
|
+
`Wave: ${trailerValue(wave.wave)}`,
|
|
364
|
+
`Attempt: ${wave.attempt}`,
|
|
365
|
+
`Verdict: ${trailerValue(wave.verdict)}`,
|
|
366
|
+
`Window-Start: ${trailerValue(wave.windowStart)}`,
|
|
367
|
+
`Materializer: ${MATERIALIZER_VERSION}`
|
|
368
|
+
];
|
|
369
|
+
if (wave.batchId)
|
|
370
|
+
trailers.push(`Batch-Id: ${trailerValue(wave.batchId)}`);
|
|
371
|
+
for (const id of wave.requirementIds ?? [])
|
|
372
|
+
trailers.push(`Requirement-Id: ${trailerValue(id)}`);
|
|
373
|
+
if (wave.operator)
|
|
374
|
+
trailers.push(`Operator: ${trailerValue(wave.operator)}`);
|
|
375
|
+
if (wave.revertOf) {
|
|
376
|
+
trailers.push(`Revert-Of: ${trailerValue(`${wave.revertOf.sessionId}/${wave.revertOf.wave}`)}`);
|
|
377
|
+
}
|
|
378
|
+
// An ambiguous wave's unobserved components are UNKNOWN in this commit, not unchanged — the flag
|
|
379
|
+
// is what lets a later drift comparison say so instead of guessing.
|
|
380
|
+
if (wave.ambiguous)
|
|
381
|
+
trailers.push("Ambiguous-Delivery: true");
|
|
382
|
+
// The change scan could not bracket the deploy, so this commit's position in history approximates
|
|
383
|
+
// nothing. Said here rather than inferred, because order looks equally confident either way.
|
|
384
|
+
if (wave.windowSuspect)
|
|
385
|
+
trailers.push("Ordering-Unreliable: true");
|
|
386
|
+
for (const key of drifted)
|
|
387
|
+
trailers.push(`Drift: ${trailerValue(key)}`);
|
|
388
|
+
// Recorded rather than silently dropped: a reader of this history is entitled to see that the
|
|
389
|
+
// mirror's copy of a component was stale AND why it was not treated as an outside edit. One
|
|
390
|
+
// trailer per (container, child) pair, so nothing has to be truncated to fit a line.
|
|
391
|
+
for (const entry of attributed) {
|
|
392
|
+
for (const child of entry.children) {
|
|
393
|
+
trailers.push(`Drift-Explained: ${trailerValue(entry.key)} <- ${trailerValue(child)}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
// NOT a `Drift:` trailer, deliberately: this history must not record as an accusation something
|
|
397
|
+
// that is a fact about our own readers. Written all the same, because a comparison that could not
|
|
398
|
+
// be made is a gap in the record and a reader of this history is entitled to see where.
|
|
399
|
+
for (const entry of incomparable) {
|
|
400
|
+
const cause = entry.reason === "READER_CHANGED"
|
|
401
|
+
? `${trailerValue(entry.recorded ?? "?")} -> ${trailerValue(entry.observed ?? "?")}`
|
|
402
|
+
: `facet unread: ${trailerValue((entry.facets ?? []).join(", "))}`;
|
|
403
|
+
trailers.push(`Drift-Not-Comparable: ${trailerValue(entry.key)} <> ${cause} <> differs: ${facetList(entry.differingKeys)}`);
|
|
404
|
+
}
|
|
405
|
+
// What this commit's post-image does NOT contain. A record of the org that silently omits a facet
|
|
406
|
+
// it could not read is a false record, and the absence is invisible in the tree by construction —
|
|
407
|
+
// the reader deletes the key rather than leaving an unreliable value. Said out loud here so a
|
|
408
|
+
// reader of history sees the bound instead of a clean-looking absence.
|
|
409
|
+
for (const entry of incompleteFacetTrailers(wave.components))
|
|
410
|
+
trailers.push(`Incomplete-Facet: ${entry}`);
|
|
411
|
+
return `${subject}\n\n${trailers.join("\n")}\n`;
|
|
412
|
+
}
|
|
413
|
+
async function writeRepoFile(repoDir, rel, content) {
|
|
414
|
+
const abs = path.join(repoDir, safeRelPath(rel));
|
|
415
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
416
|
+
await fs.writeFile(abs, content, "utf8");
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* The org's mirror directory, initialized if absent, with its configuration pinned.
|
|
420
|
+
*
|
|
421
|
+
* The pinning is repo-local AND environment-level (see `gitEnv`) on purpose: the repo config
|
|
422
|
+
* documents the intent where a human looking at the mirror can read it, and the environment is
|
|
423
|
+
* what actually makes it hermetic when a machine's global config disagrees.
|
|
424
|
+
*/
|
|
425
|
+
export async function ensureRepo(orgId, opts = {}) {
|
|
426
|
+
const dir = mirrorPath(orgId, opts.root);
|
|
427
|
+
const isRepo = await fs
|
|
428
|
+
.stat(path.join(dir, ".git"))
|
|
429
|
+
.then(() => true)
|
|
430
|
+
.catch(() => false);
|
|
431
|
+
if (isRepo) {
|
|
432
|
+
const recorded = (await git(dir, ["config", "--local", "in2.orgId"])).stdout.trim();
|
|
433
|
+
if (recorded && recorded.toLowerCase() !== orgId.toLowerCase()) {
|
|
434
|
+
throw new OrgRepoError("ORG_ID_MISMATCH", `the mirror at ${dir} records org ${recorded}, not ${orgId}. Refusing — one history per org is the invariant that makes this readable at all, and merging two orgs' components into one would be unrecoverable.`);
|
|
435
|
+
}
|
|
436
|
+
return dir;
|
|
437
|
+
}
|
|
438
|
+
await fs.mkdir(dir, { recursive: true });
|
|
439
|
+
await gitOrThrow(dir, ["init", "--quiet", "--initial-branch=main", "--object-format=sha1"]);
|
|
440
|
+
for (const [key, value] of [
|
|
441
|
+
["user.name", AUTHOR_NAME],
|
|
442
|
+
["user.email", AUTHOR_EMAIL],
|
|
443
|
+
// Byte fidelity. A mirror whose line endings depend on the machine that wrote it is not a
|
|
444
|
+
// mirror; it is a second history that happens to look similar.
|
|
445
|
+
["core.autocrlf", "false"],
|
|
446
|
+
["core.safecrlf", "false"],
|
|
447
|
+
["core.fileMode", "false"],
|
|
448
|
+
["core.symlinks", "false"],
|
|
449
|
+
// macOS precomposes NFD filenames on read and Linux does not, so a component whose name
|
|
450
|
+
// carries an accent would hash to two different trees on two machines. Stated rather than
|
|
451
|
+
// platform-defaulted, even though Salesforce API names are ASCII — layout NAMES are not.
|
|
452
|
+
["core.precomposeunicode", "true"],
|
|
453
|
+
["core.hooksPath", path.join(dir, ".git", "no-hooks")],
|
|
454
|
+
["commit.gpgsign", "false"],
|
|
455
|
+
["tag.gpgsign", "false"],
|
|
456
|
+
["gc.auto", "0"],
|
|
457
|
+
["in2.orgId", orgId]
|
|
458
|
+
]) {
|
|
459
|
+
await gitOrThrow(dir, ["config", "--local", key, value]);
|
|
460
|
+
}
|
|
461
|
+
// `-text` over everything: no attribute may decide to normalize a byte on one machine and not
|
|
462
|
+
// another. `-diff` on the source tree keeps binary static-resource bytes out of a text diff.
|
|
463
|
+
await writeRepoFile(dir, ".gitattributes", "* -text\n");
|
|
464
|
+
await writeRepoFile(dir, "README.md", [
|
|
465
|
+
`# IN2 org mirror — ${orgId}`,
|
|
466
|
+
"",
|
|
467
|
+
"Every component IN2 has TOUCHED in this org, as the org reported it after each wave. A",
|
|
468
|
+
"component a wave merely READ is not here — the projection behind a wave is deliberately wider",
|
|
469
|
+
"than its work, and this history is a record of deliveries, not of reads.",
|
|
470
|
+
"",
|
|
471
|
+
"AS THE ORG REPORTED IT — AND SOMETIMES THE ORG DOES NOT REPORT. A read can fail to reach a",
|
|
472
|
+
"facet (SOAP `readMetadata` omits Layout related lists without saying so), and the reader then",
|
|
473
|
+
"DROPS that facet rather than storing a value it cannot trust. A snapshot missing a facet for",
|
|
474
|
+
"that reason says so in its own `incompleteFacets`, and the commit carries an",
|
|
475
|
+
"`Incomplete-Facet:` trailer. Where you see one, the facet was NEVER READ — it is not a claim",
|
|
476
|
+
"that it is empty, and authoring a full-file replacement from that snapshot would delete",
|
|
477
|
+
"whatever the org actually has there.",
|
|
478
|
+
"",
|
|
479
|
+
"- `projection/` — the post-image canonical JSON, one file per component. Exact about what it",
|
|
480
|
+
" contains, and explicit about what it does not: a deleted component's file is removed, and a",
|
|
481
|
+
" facet that could not be read is named in `incompleteFacets`.",
|
|
482
|
+
"- `source/` — the source-format XML that was deployed. A DEPLOY LOG, not a live tree: a",
|
|
483
|
+
" deleted component's last deployed bytes stay here on purpose.",
|
|
484
|
+
"",
|
|
485
|
+
"This is a REBUILDABLE CACHE derived from the evidence bundles, not an authority: it can be",
|
|
486
|
+
"deleted and re-materialized, and every commit carries the materializer version that produced",
|
|
487
|
+
"it. Do not edit it by hand and do not persist its commit SHAs — a rebuild rewrites them by",
|
|
488
|
+
"design.",
|
|
489
|
+
""
|
|
490
|
+
].join("\n"));
|
|
491
|
+
await gitOrThrow(dir, ["add", "-A", "."]);
|
|
492
|
+
await gitOrThrow(dir, ["commit", "--quiet", "-F", "-"], {
|
|
493
|
+
date: EPOCH,
|
|
494
|
+
input: `mirror: ${orgId}\n\nOrg-Id: ${orgId}\nMaterializer: ${MATERIALIZER_VERSION}\n`
|
|
495
|
+
});
|
|
496
|
+
return dir;
|
|
497
|
+
}
|
|
498
|
+
/** A repo-relative path as git wants it, whatever separator this platform joined it with. */
|
|
499
|
+
const gitPath = (...parts) => parts.flatMap((p) => p.split(/[/\\]/)).filter(Boolean).join("/");
|
|
500
|
+
/** The identity a snapshot blob names, or null if it names none. The one parse, shared. */
|
|
501
|
+
function keyFromSnapshotJson(text) {
|
|
502
|
+
try {
|
|
503
|
+
const snapshot = JSON.parse(text);
|
|
504
|
+
if (typeof snapshot.typeName === "string" && typeof snapshot.fullName === "string") {
|
|
505
|
+
return `${snapshot.typeName}:${snapshot.fullName}`;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Not a snapshot, or not JSON at all. Unnamed, therefore unusable as evidence.
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* A committed projection file's component identity, read from the BLOB — never guessed from the
|
|
515
|
+
* path. The projection sanitizes names on the way to disk (`projection/project.ts` `safeName`), so
|
|
516
|
+
* inverting a path is a guess, and a guessed identity here would either forge an attribution or
|
|
517
|
+
* forge an accusation. Many blobs, ONE git process.
|
|
518
|
+
*
|
|
519
|
+
* `git show <rev>:<path>` is one process per blob, so resolving a history's component identities
|
|
520
|
+
* cost one child process PER COMPONENT — 25 launches for a 24-component wave, serially, at the
|
|
521
|
+
* batch close of every run. `git cat-file --batch` takes the revisions on stdin and streams every
|
|
522
|
+
* blob back, which is the same bytes read by the same plumbing in one launch.
|
|
523
|
+
*
|
|
524
|
+
* THE OUTPUT FORMAT, exactly, because parsing it wrong is how a batch silently returns the wrong
|
|
525
|
+
* component's identity. For each input line git emits either
|
|
526
|
+
*
|
|
527
|
+
* <sha> <type> <size>\n<size bytes of content>\n (found)
|
|
528
|
+
* <the input line> missing\n (not found)
|
|
529
|
+
*
|
|
530
|
+
* The size prefix is what makes this safe: content is taken by BYTE COUNT, never by scanning for a
|
|
531
|
+
* delimiter, so a snapshot containing a newline — every one of them does — cannot desynchronize the
|
|
532
|
+
* stream. Reading is done over a Buffer rather than a string for the same reason: `size` counts
|
|
533
|
+
* bytes, and a multi-byte character in a label would make a character-indexed slice drift.
|
|
534
|
+
*
|
|
535
|
+
* Answers are returned POSITIONALLY, one per request, so a caller's mapping is by index and never
|
|
536
|
+
* by re-deriving identity from a path — which is the property this module refuses to rely on.
|
|
537
|
+
*
|
|
538
|
+
* A `missing` answer is a null, exactly as a failed `git show` was: a deleted file whose parent
|
|
539
|
+
* revision does not exist (the root commit) and a path that was never committed both legitimately
|
|
540
|
+
* resolve to nothing, and neither may be reported as a component.
|
|
541
|
+
*/
|
|
542
|
+
async function keysFromBlobs(dir, requests) {
|
|
543
|
+
const keys = [];
|
|
544
|
+
// CHUNKED, because one batch means one stdout buffer. `git show` capped each response at
|
|
545
|
+
// `maxBuffer` (64 MB) INDIVIDUALLY; a single batch would have to hold every blob at once, and
|
|
546
|
+
// overflowing it is fail-silent in the dangerous direction — execFile errors, every key comes
|
|
547
|
+
// back null, and the contamination report reads as "this org delivered nothing" rather than as a
|
|
548
|
+
// failure. A live mirror runs ~2.3 KB per snapshot (measured: 300 components, 737 KB), so 500 per
|
|
549
|
+
// chunk is ~1 MB of buffer with a 64 MB ceiling — two orders of margin, and still one process per
|
|
550
|
+
// 500 components rather than one per component.
|
|
551
|
+
for (let start = 0; start < requests.length; start += BLOB_BATCH_SIZE) {
|
|
552
|
+
keys.push(...(await keysFromBlobChunk(dir, requests.slice(start, start + BLOB_BATCH_SIZE))));
|
|
553
|
+
}
|
|
554
|
+
return keys;
|
|
555
|
+
}
|
|
556
|
+
const BLOB_BATCH_SIZE = 500;
|
|
557
|
+
/**
|
|
558
|
+
* How many CONTENT BYTES follow this `git cat-file --batch` header line, or NaN if it carries none.
|
|
559
|
+
*
|
|
560
|
+
* THE ANTI-DESYNC GUARD, exported so it can be pinned directly. Git answers each request with either
|
|
561
|
+
* `<sha> <type> <size>` followed by that many bytes, or a non-answer that echoes the INPUT LINE
|
|
562
|
+
* (`<sha>:<path> missing`) and carries no content at all. Splitting on spaces cannot tell those
|
|
563
|
+
* apart: a path containing a space makes a non-answer split into three parts too, and reading its
|
|
564
|
+
* last field as a content length would consume a bogus number of bytes and shift every record after
|
|
565
|
+
* it — returning one component's identity under another's name, silently, in a report that names
|
|
566
|
+
* people's work.
|
|
567
|
+
*
|
|
568
|
+
* So the object id and the size must BOTH look like what git emits. `safeName` keeps spaces out of
|
|
569
|
+
* the paths this repo writes today, which makes this defence in depth rather than a live fix; it
|
|
570
|
+
* costs one regex, and the failure it guards against is silent and unrecoverable.
|
|
571
|
+
*/
|
|
572
|
+
export function blobContentLength(header) {
|
|
573
|
+
const match = /^([0-9a-f]{40,64}) [a-z]+ (\d+)$/.exec(header);
|
|
574
|
+
return match ? Number(match[2]) : Number.NaN;
|
|
575
|
+
}
|
|
576
|
+
async function keysFromBlobChunk(dir, requests) {
|
|
577
|
+
if (requests.length === 0)
|
|
578
|
+
return [];
|
|
579
|
+
// `--buffer` keeps git from flushing per record, which matters once the input is hundreds of
|
|
580
|
+
// lines: without it the read side pays a syscall per blob and the batching buys much less.
|
|
581
|
+
const run = await gitRaw(dir, ["cat-file", "--batch", "--buffer"], {
|
|
582
|
+
input: `${requests.map((r) => `${r.sha}${r.removed ? "^" : ""}:${r.file}`).join("\n")}\n`
|
|
583
|
+
});
|
|
584
|
+
if (!run.ok) {
|
|
585
|
+
// The batch itself failed (a broken repo, git missing a feature). Nothing is guessed: every
|
|
586
|
+
// request resolves to "unnamed", which is what a failed per-blob read already produced.
|
|
587
|
+
return requests.map(() => null);
|
|
588
|
+
}
|
|
589
|
+
const out = run.stdoutBuffer;
|
|
590
|
+
const keys = [];
|
|
591
|
+
let cursor = 0;
|
|
592
|
+
for (let i = 0; i < requests.length; i++) {
|
|
593
|
+
const newline = out.indexOf(0x0a, cursor);
|
|
594
|
+
if (newline === -1)
|
|
595
|
+
break; // truncated output — remaining requests stay unresolved
|
|
596
|
+
const header = out.toString("utf8", cursor, newline);
|
|
597
|
+
cursor = newline + 1;
|
|
598
|
+
// `<sha> <type> <size>` is the only shape carrying content. Every other answer git gives —
|
|
599
|
+
// `<input> missing`, `<input> ambiguous` — is a non-answer whose record ends at this newline,
|
|
600
|
+
// so nothing may be skipped past it.
|
|
601
|
+
//
|
|
602
|
+
// MATCHED STRICTLY rather than by splitting on spaces. A non-answer echoes the INPUT LINE
|
|
603
|
+
// (`<sha>:<path> missing`), and a path containing a space would split into three parts and be
|
|
604
|
+
// mistaken for a header — consuming a bogus content length and DESYNCHRONIZING the rest of the
|
|
605
|
+
// stream, which returns one component's identity under another's name in a report that names
|
|
606
|
+
// people's work. The projection's own `safeName` maps spaces out of the paths it writes, so
|
|
607
|
+
// this is defence in depth against a future path source rather than a bug being fixed; the
|
|
608
|
+
// cost is a regex, and the failure it guards is silent and unrecoverable.
|
|
609
|
+
// (The size prefix itself is NOT defence in depth — see the byte-offset note above; a
|
|
610
|
+
// character-indexed read really does corrupt this stream, and a test pins it.)
|
|
611
|
+
const size = blobContentLength(header);
|
|
612
|
+
if (!Number.isSafeInteger(size) || size < 0 || cursor + size > out.length) {
|
|
613
|
+
keys.push(null);
|
|
614
|
+
continue;
|
|
615
|
+
}
|
|
616
|
+
keys.push(keyFromSnapshotJson(out.toString("utf8", cursor, cursor + size)));
|
|
617
|
+
cursor += size + 1; // the trailing newline git writes after every blob's content
|
|
618
|
+
}
|
|
619
|
+
while (keys.length < requests.length)
|
|
620
|
+
keys.push(null);
|
|
621
|
+
return keys;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The container's snapshot with the named child entries removed from the collections that embed
|
|
625
|
+
* them.
|
|
626
|
+
*
|
|
627
|
+
* Scoped twice over, because this function's whole job is to make a difference disappear: only the
|
|
628
|
+
* collections `projection/diff.ts` already classifies as child components are touched, and within
|
|
629
|
+
* them only entries whose `fullName` is one of `leaves`. A parent-level facet and a child this
|
|
630
|
+
* mirror never delivered both survive into the comparison, which is what keeps a suppression from
|
|
631
|
+
* ever being wider than its evidence.
|
|
632
|
+
*/
|
|
633
|
+
function withoutChildren(canonical, leaves) {
|
|
634
|
+
const snapshot = JSON.parse(canonical);
|
|
635
|
+
const metadata = snapshot.state?.metadata;
|
|
636
|
+
if (!metadata)
|
|
637
|
+
return canonical;
|
|
638
|
+
for (const name of CHILD_COLLECTION_NAMES) {
|
|
639
|
+
const collection = metadata[name];
|
|
640
|
+
if (!Array.isArray(collection))
|
|
641
|
+
continue;
|
|
642
|
+
metadata[name] = collection.filter((entry) => {
|
|
643
|
+
const fullName = entry?.fullName;
|
|
644
|
+
return !(typeof fullName === "string" && leaves.has(fullName));
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Can this mirror's OWN recorded history account for the whole disagreement? (live-caught, PV Arm A)
|
|
651
|
+
*
|
|
652
|
+
* A `CustomObject` read-back EMBEDS its children, but the children project as their own components
|
|
653
|
+
* — so a wave delivering only fields or list views writes the CHILD files and leaves the parent's
|
|
654
|
+
* file untouched. The parent's copy is then stale by construction, and the next wave to read the
|
|
655
|
+
* parent found a shape this install had itself produced and carded it as an outside edit. On the PV
|
|
656
|
+
* corpus run that fired on `CustomObject:PV_Asset__c` at w5, where the entire delta was w2's four
|
|
657
|
+
* fields and w4's list views, validation rules and compact layout.
|
|
658
|
+
*
|
|
659
|
+
* ATTRIBUTION IS POSITIVE OR IT DOES NOT HAPPEN. A child only counts when this mirror holds a
|
|
660
|
+
* commit delivering it that is STRICTLY NEWER than the parent's last recorded read — otherwise the
|
|
661
|
+
* parent's file already carried that child and is not stale. Both snapshots then have exactly those
|
|
662
|
+
* entries removed and must come out IDENTICAL; one unexplained byte anywhere and this returns null,
|
|
663
|
+
* the component is carded in full, and nothing is suppressed. Anything that goes wrong reading git
|
|
664
|
+
* lands in the same place, because "drift stands" is the direction a failure here must fail in.
|
|
665
|
+
*
|
|
666
|
+
* WHAT THIS DOES NOT SEE, stated rather than implied: if an admin edited one of those same children
|
|
667
|
+
* after IN2 delivered it, that edit is inside the removed region and this will not notice. It is not
|
|
668
|
+
* lost — each child has its own mirrored file, so the edit surfaces as drift on the CHILD the moment
|
|
669
|
+
* a wave's pre-image includes it, which is where a claim about that child belongs. Comparing the
|
|
670
|
+
* embedded entry against the child's own snapshot is not available as a shortcut: the org returns
|
|
671
|
+
* the two at different depths (measured on the PV run — a field's `defaultValue` is present standalone
|
|
672
|
+
* and absent inline), so a byte comparison there would manufacture exactly the false card this fixes.
|
|
673
|
+
*/
|
|
674
|
+
async function attributeToOwnHistory(args) {
|
|
675
|
+
const { repoDir, component, committed, pre } = args;
|
|
676
|
+
// Only a CustomObject read-back embeds other components. Every other type's snapshot is its own
|
|
677
|
+
// content, so a disagreement there is a disagreement about that component and nothing else.
|
|
678
|
+
if (component.typeName !== "CustomObject")
|
|
679
|
+
return null;
|
|
680
|
+
try {
|
|
681
|
+
const rel = gitPath("projection", safeRelPath(component.file));
|
|
682
|
+
const lastRead = (await git(repoDir, ["log", "-1", "--format=%H", "--", rel])).stdout.trim();
|
|
683
|
+
if (!lastRead)
|
|
684
|
+
return null;
|
|
685
|
+
const out = await gitOrThrow(repoDir, [
|
|
686
|
+
"log",
|
|
687
|
+
`${lastRead}..HEAD`,
|
|
688
|
+
"--name-status",
|
|
689
|
+
"--no-renames",
|
|
690
|
+
`--format=${RECORD}%H${FIELD}%b${FIELD}`,
|
|
691
|
+
"--",
|
|
692
|
+
"projection"
|
|
693
|
+
]);
|
|
694
|
+
const prefix = `${component.fullName}.`;
|
|
695
|
+
const children = new Map(); // component key -> "<sessionId>/<wave>"
|
|
696
|
+
const leaves = new Set();
|
|
697
|
+
// Collected first, resolved in ONE `git cat-file --batch` — see keysFromBlobs. This runs on the
|
|
698
|
+
// APPLY path (every wave that finds drift on a CustomObject), and a `git show` per changed file
|
|
699
|
+
// put a process launch behind each one of them.
|
|
700
|
+
const requests = [];
|
|
701
|
+
const attribution = [];
|
|
702
|
+
for (const record of out.split(RECORD)) {
|
|
703
|
+
if (!record.trim())
|
|
704
|
+
continue;
|
|
705
|
+
const [sha = "", body = "", changes = ""] = record.split(FIELD);
|
|
706
|
+
const trailers = parseTrailers(body);
|
|
707
|
+
for (const line of changes.split("\n")) {
|
|
708
|
+
const match = /^([AMD])\t(projection\/.+)$/.exec(line);
|
|
709
|
+
if (!match)
|
|
710
|
+
continue;
|
|
711
|
+
requests.push({ file: match[2], sha, removed: match[1] === "D" });
|
|
712
|
+
attribution.push(`${trailers.get("session-id")?.[0] ?? "?"}/${trailers.get("wave")?.[0] ?? "?"}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
const keys = await keysFromBlobs(repoDir, requests);
|
|
716
|
+
for (const [i, key] of keys.entries()) {
|
|
717
|
+
if (!key)
|
|
718
|
+
continue;
|
|
719
|
+
const fullName = key.slice(key.indexOf(":") + 1);
|
|
720
|
+
if (!fullName.startsWith(prefix))
|
|
721
|
+
continue;
|
|
722
|
+
leaves.add(fullName.slice(prefix.length));
|
|
723
|
+
// FIRST mention wins, exactly as before: the walk is newest-commit-first, so the first
|
|
724
|
+
// attribution seen for a key is the most recent wave to have delivered it.
|
|
725
|
+
if (!children.has(key))
|
|
726
|
+
children.set(key, attribution[i]);
|
|
727
|
+
}
|
|
728
|
+
if (children.size === 0)
|
|
729
|
+
return null;
|
|
730
|
+
if (withoutChildren(committed, leaves) !== withoutChildren(pre, leaves))
|
|
731
|
+
return null;
|
|
732
|
+
return {
|
|
733
|
+
key: componentKey(component),
|
|
734
|
+
children: [...children.keys()].sort(),
|
|
735
|
+
waves: [...new Set(children.values())].sort()
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Git could not answer. An unproven attribution is not an attribution.
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Which touched components disagree with what the mirror last committed (R15).
|
|
745
|
+
*
|
|
746
|
+
* Positive evidence only, in both directions. A component the mirror has never held is UNKNOWN, not
|
|
747
|
+
* drifted — this history covers what IN2 touched, so absence says nothing about the org. And a
|
|
748
|
+
* disagreement is only downgraded out of `drifted` when this mirror's own commits account for every
|
|
749
|
+
* byte of it (`attributeToOwnHistory`); partial attribution leaves the warning standing.
|
|
750
|
+
*
|
|
751
|
+
* THREE OUTCOMES, not two. Before a disagreement can be attributed or carded it has to be a
|
|
752
|
+
* disagreement about the ORG, and a pair of snapshots read by different readers is not (PV Arm B —
|
|
753
|
+
* see `snapshotComparability.ts`). Those come back as `incomparable`: reported in their own right,
|
|
754
|
+
* never as drift, never dropped. The order matters — comparability is asked FIRST, because
|
|
755
|
+
* attributing a reader-shape difference to this history's own children would be a second wrong
|
|
756
|
+
* answer dressed as a right one.
|
|
757
|
+
*
|
|
758
|
+
* A COMPONENT DELIVERED TWICE IN ONE BATCH NEEDS NOTHING HERE (plan 2026-08-03-002 U7). The second
|
|
759
|
+
* wave's pre-image is the org as the FIRST wave left it, and the first wave committed exactly that
|
|
760
|
+
* — so the two agree and no `Drift:` trailer is written. That is not a special case being handled;
|
|
761
|
+
* it is the ordinary comparison coming out clean, which is why a trailer appearing on such a chain
|
|
762
|
+
* would be a real defect (the second wave measuring against something other than the first wave's
|
|
763
|
+
* work) rather than an over-eager warning. `applyLoopMirror.test.ts` pins it end to end against real
|
|
764
|
+
* git for that reason.
|
|
765
|
+
*/
|
|
766
|
+
async function computeDrift(repoDir, preComponents) {
|
|
767
|
+
const drifted = [];
|
|
768
|
+
const attributed = [];
|
|
769
|
+
const incomparable = [];
|
|
770
|
+
for (const component of preComponents) {
|
|
771
|
+
const abs = path.join(repoDir, "projection", safeRelPath(component.file));
|
|
772
|
+
const committed = await fs.readFile(abs, "utf8").catch(() => null);
|
|
773
|
+
if (committed === null)
|
|
774
|
+
continue;
|
|
775
|
+
const pre = canonicalSnapshot(component.snapshot);
|
|
776
|
+
if (committed === pre)
|
|
777
|
+
continue;
|
|
778
|
+
const errors = component.snapshot?.errors;
|
|
779
|
+
const verdict = assessComparability({
|
|
780
|
+
recorded: committed,
|
|
781
|
+
observed: pre,
|
|
782
|
+
...(Array.isArray(errors) ? { observedErrors: errors } : {})
|
|
783
|
+
});
|
|
784
|
+
if (verdict.kind !== "COMPARABLE") {
|
|
785
|
+
incomparable.push({
|
|
786
|
+
key: componentKey(component),
|
|
787
|
+
reason: verdict.kind,
|
|
788
|
+
...(verdict.kind === "READER_CHANGED"
|
|
789
|
+
? { recorded: readerIdentityLabel(verdict.recorded), observed: readerIdentityLabel(verdict.observed) }
|
|
790
|
+
: { facets: verdict.facets }),
|
|
791
|
+
differingKeys: verdict.differingKeys
|
|
792
|
+
});
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const explained = await attributeToOwnHistory({ repoDir, component, committed, pre });
|
|
796
|
+
if (explained)
|
|
797
|
+
attributed.push(explained);
|
|
798
|
+
else
|
|
799
|
+
drifted.push(componentKey(component));
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
drifted: drifted.sort(),
|
|
803
|
+
attributed: attributed.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)),
|
|
804
|
+
incomparable: incomparable.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
async function overlayAndCommitLocked(input) {
|
|
808
|
+
const { wave } = input;
|
|
809
|
+
const repoDir = await ensureRepo(wave.orgId, input);
|
|
810
|
+
// A wave carrying its own `drifted` states the finding outright and is taken at its word; there
|
|
811
|
+
// is no attribution to redo, because the analysis that produced it is not on offer here.
|
|
812
|
+
const analysis = wave.drifted
|
|
813
|
+
? { drifted: wave.drifted, attributed: [], incomparable: [] }
|
|
814
|
+
: input.preComponents
|
|
815
|
+
? await computeDrift(repoDir, input.preComponents)
|
|
816
|
+
: { drifted: [], attributed: [], incomparable: [] };
|
|
817
|
+
const { drifted, attributed, incomparable } = analysis;
|
|
818
|
+
for (const component of wave.components) {
|
|
819
|
+
await writeRepoFile(repoDir, path.join("projection", component.file), canonicalSnapshot(component.snapshot));
|
|
820
|
+
}
|
|
821
|
+
for (const file of wave.sourceFiles) {
|
|
822
|
+
await writeRepoFile(repoDir, path.join("source", file.relPath), file.content);
|
|
823
|
+
}
|
|
824
|
+
for (const removal of wave.removed) {
|
|
825
|
+
await fs.rm(path.join(repoDir, "projection", safeRelPath(removal.file)), { force: true });
|
|
826
|
+
for (const rel of removal.sourceRelPaths ?? []) {
|
|
827
|
+
await fs.rm(path.join(repoDir, "source", safeRelPath(rel)), { force: true });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
await gitOrThrow(repoDir, ["add", "-A", "."]);
|
|
831
|
+
// Commit IFF the tree actually moved. A re-delivery of unchanged content is a real and common
|
|
832
|
+
// case (a wave re-run after a lint fix elsewhere), and recording it would turn history into a
|
|
833
|
+
// log of applies rather than a record of the org.
|
|
834
|
+
const dirty = await git(repoDir, ["diff", "--cached", "--quiet"]);
|
|
835
|
+
if (dirty.exitCode === 0)
|
|
836
|
+
return { sha: null, committed: false, drifted, attributed, incomparable };
|
|
837
|
+
const date = new Date(wave.windowStart);
|
|
838
|
+
const stamp = Number.isNaN(date.getTime()) ? EPOCH : date.toISOString();
|
|
839
|
+
await gitOrThrow(repoDir, ["commit", "--quiet", "-F", "-"], {
|
|
840
|
+
date: stamp,
|
|
841
|
+
input: commitMessage(wave, wave.components.length, wave.removed.length, drifted, attributed, incomparable)
|
|
842
|
+
});
|
|
843
|
+
const sha = (await gitOrThrow(repoDir, ["rev-parse", "HEAD"])).trim();
|
|
844
|
+
return { sha, committed: true, drifted, attributed, incomparable };
|
|
845
|
+
}
|
|
846
|
+
/** Overlay one wave onto its org's mirror and commit iff the tree changed. Serialized per org. */
|
|
847
|
+
export async function overlayAndCommit(input) {
|
|
848
|
+
return withOrgLock(input.wave.orgId, input, () => overlayAndCommitLocked(input));
|
|
849
|
+
}
|
|
850
|
+
// ---------------------------------------------------------------------------------------------
|
|
851
|
+
// bundles in, history out
|
|
852
|
+
// ---------------------------------------------------------------------------------------------
|
|
853
|
+
async function readJson(file) {
|
|
854
|
+
try {
|
|
855
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* What the wave TOUCHED, as its own request records it — or null when the bundle cannot say.
|
|
863
|
+
*
|
|
864
|
+
* A wave's projection is deliberately WIDER than its work: it samples precedent (see
|
|
865
|
+
* projection/precedent.ts), and a wide-object scope enumerates an object's whole field list to read
|
|
866
|
+
* two of them. `request.json` is the only artifact in the bundle that states intent rather than
|
|
867
|
+
* observation, which is what makes it the authority here.
|
|
868
|
+
*
|
|
869
|
+
* CONTAINERS ARE EXPANDED, and the boundary is exactly the deployed BYTES. A CustomObject file can
|
|
870
|
+
* carry `<fields>` inline, so a child the request never names on its own is still a real
|
|
871
|
+
* wave-caused change — `expandComponent` reads the submitted XML and names it. What is NOT expanded
|
|
872
|
+
* is a container's other children: an `objectFacetUpdates` entry structurally cannot carry child
|
|
873
|
+
* collections (the apply loop refuses one that does), so a facet update touches the object and
|
|
874
|
+
* nothing beneath it. Measured on the live corpus, the difference is 63 components that a
|
|
875
|
+
* whole-subtree rule would have attributed to a wave that only reassigned a compact layout.
|
|
876
|
+
*
|
|
877
|
+
* NULL IS NOT AN EMPTY SET. A bundle with no request — a pre-U2 artifact, or a foreign parcel
|
|
878
|
+
* built by something else — knows nothing about what was touched, and an empty set would silently
|
|
879
|
+
* erase real deliveries from history. Null means "fall back to the manifest flag"; an empty set
|
|
880
|
+
* means "this request touched nothing", which is a different and answerable claim.
|
|
881
|
+
*
|
|
882
|
+
* DELIBERATELY BLIND TO INTENT (plan 2026-08-03-002 U7). Each edit in `request.json` now also carries
|
|
883
|
+
* an `intent` of `create`/`update`, and nothing here reads it. Identity is still
|
|
884
|
+
* `(typeName, fullName)`: a component this batch delivers twice produces two waves naming the same
|
|
885
|
+
* key, which is exactly right — the mirror records a SEQUENCE of states of one component, and two
|
|
886
|
+
* commits touching one key is what that looks like. Branching on intent would only let a mis-declared
|
|
887
|
+
* entry decide what the mirror believes was touched, and the deployed bytes already answer that.
|
|
888
|
+
*
|
|
889
|
+
* A bundle written before intent existed carries none, parses identically, and is graded identically.
|
|
890
|
+
*/
|
|
891
|
+
function touchedKeys(request) {
|
|
892
|
+
if (!request)
|
|
893
|
+
return null;
|
|
894
|
+
// A request that states none of the three says nothing about what was touched — see NULL above.
|
|
895
|
+
if (![request.edits, request.deletes, request.facetUpdates].some(Array.isArray))
|
|
896
|
+
return null;
|
|
897
|
+
const keys = new Set();
|
|
898
|
+
for (const item of request.edits ?? []) {
|
|
899
|
+
if (typeof item?.typeName !== "string" || typeof item?.fullName !== "string")
|
|
900
|
+
continue;
|
|
901
|
+
// The file itself AND what it contains. `expandComponent` answers a different question — which
|
|
902
|
+
// CONFIGURABLE components a deploy produced, and a pure container like SharingRules is not one
|
|
903
|
+
// of those, so it drops the parent. The mirror wants the post-image of the artifact that was
|
|
904
|
+
// deployed, and `SharingRules:Obj__c` is exactly what a projection records for that deploy.
|
|
905
|
+
// Live-caught while measuring this change: taking the expansion alone silently stopped three
|
|
906
|
+
// sharing-rule waves from committing at all.
|
|
907
|
+
keys.add(`${item.typeName}:${item.fullName}`);
|
|
908
|
+
for (const part of expandComponent(item.typeName, item.fullName, item.xml)) {
|
|
909
|
+
keys.add(`${part.typeName}:${part.fullName}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
// A delete carries no XML; it names exactly what it removes.
|
|
913
|
+
for (const item of request.deletes ?? []) {
|
|
914
|
+
if (typeof item?.typeName === "string" && typeof item?.fullName === "string") {
|
|
915
|
+
keys.add(`${item.typeName}:${item.fullName}`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// A facet update edits the object itself; the object IS the touched component.
|
|
919
|
+
for (const facet of request.facetUpdates ?? []) {
|
|
920
|
+
if (typeof facet?.objectApiName === "string")
|
|
921
|
+
keys.add(`CustomObject:${facet.objectApiName}`);
|
|
922
|
+
}
|
|
923
|
+
return keys;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Does this manifest entry belong in the mirror — i.e. did the wave touch it?
|
|
927
|
+
*
|
|
928
|
+
* Two filters, because they cover different bundles and neither covers all of them:
|
|
929
|
+
*
|
|
930
|
+
* the FLAG marks precedent at projection time, so it travels with every bundle written from
|
|
931
|
+
* here on, including into a parcel a rebuild on another machine replays.
|
|
932
|
+
* the REQUEST covers everything already on disk, which carries no flag and never will.
|
|
933
|
+
*
|
|
934
|
+
* Nothing beyond the request is inferred. In particular, the default Layout the org auto-creates
|
|
935
|
+
* alongside a new object is a genuine wave-caused change that this does NOT record, because the
|
|
936
|
+
* wave's projection never read it and the mirror has no post-image to commit. That is a bounded,
|
|
937
|
+
* one-directional gap: the mirror under-claims, and under-claiming reads as UNKNOWN, which is what
|
|
938
|
+
* the whole design already says absence means. Over-claiming does not — it reads as a delivery,
|
|
939
|
+
* and a delivery is a claim about somebody's work.
|
|
940
|
+
*/
|
|
941
|
+
function isTouched(entry, touched) {
|
|
942
|
+
if (entry.precedent)
|
|
943
|
+
return false;
|
|
944
|
+
if (!touched)
|
|
945
|
+
return true;
|
|
946
|
+
return touched.has(`${entry.typeName}:${entry.fullName}`);
|
|
947
|
+
}
|
|
948
|
+
async function readProjectionComponents(dir, touched) {
|
|
949
|
+
const manifest = await readJson(path.join(dir, "projection.json"));
|
|
950
|
+
if (!manifest?.components)
|
|
951
|
+
return { components: [] };
|
|
952
|
+
const components = [];
|
|
953
|
+
for (const entry of manifest.components) {
|
|
954
|
+
if (!entry.found)
|
|
955
|
+
continue;
|
|
956
|
+
if (!isTouched(entry, touched))
|
|
957
|
+
continue;
|
|
958
|
+
const snapshot = await readJson(path.join(dir, entry.file));
|
|
959
|
+
if (snapshot === null)
|
|
960
|
+
continue;
|
|
961
|
+
components.push({ typeName: entry.typeName, fullName: entry.fullName, file: entry.file, snapshot });
|
|
962
|
+
}
|
|
963
|
+
// The org id is a fact about the PROJECTION, not about any component, so it survives the filter —
|
|
964
|
+
// a wave whose every component was filtered out still knows which mirror it belongs to.
|
|
965
|
+
return { ...(manifest.orgId ? { orgId: manifest.orgId } : {}), components };
|
|
966
|
+
}
|
|
967
|
+
/** Every file under `dir`, as repo-relative paths with their bytes. Used for the deployed tree. */
|
|
968
|
+
async function readTree(dir, prefix = "") {
|
|
969
|
+
const out = [];
|
|
970
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
971
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
972
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
973
|
+
if (entry.isDirectory())
|
|
974
|
+
out.push(...(await readTree(path.join(dir, entry.name), rel)));
|
|
975
|
+
// Bytes, not text: see MirrorWave.sourceFiles.
|
|
976
|
+
else if (entry.isFile())
|
|
977
|
+
out.push({ relPath: rel, content: await fs.readFile(path.join(dir, entry.name)) });
|
|
978
|
+
}
|
|
979
|
+
return out;
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Parse one evidence bundle into the replay unit, or null when it never changed the org.
|
|
983
|
+
*
|
|
984
|
+
* This is the other half of "one overlay function": a live apply hands `overlayAndCommit` a wave it
|
|
985
|
+
* built in memory, and a rebuild hands it a wave read back off disk. If the two disagreed about
|
|
986
|
+
* what a bundle means, rebuilding would silently produce a different history than the machine that
|
|
987
|
+
* ran the waves — which is exactly the failure the version trailer exists to make visible.
|
|
988
|
+
*/
|
|
989
|
+
export async function readWaveFromBundle(bundleDir) {
|
|
990
|
+
const verdictFile = await readJson(path.join(bundleDir, "verdict.json"));
|
|
991
|
+
const verdict = verdictFile?.verdict;
|
|
992
|
+
if (!verdict || !isStateChanging(verdict))
|
|
993
|
+
return null;
|
|
994
|
+
// The request is read FIRST because it decides which components the projections contribute: the
|
|
995
|
+
// mirror holds what the wave touched, and a projection reads more than that by design.
|
|
996
|
+
const request = await readJson(path.join(bundleDir, "request.json"));
|
|
997
|
+
const touched = touchedKeys(request);
|
|
998
|
+
const post = await readProjectionComponents(path.join(bundleDir, "post"), touched);
|
|
999
|
+
// The PRE image goes through the SAME filter, and for the same reason twice over: it is what
|
|
1000
|
+
// `computeDrift` compares against the mirror, so an untouched component that a third party edited
|
|
1001
|
+
// would otherwise surface as CHANGED-OUTSIDE-HISTORY against a wave that never went near it.
|
|
1002
|
+
const pre = await readProjectionComponents(path.join(bundleDir, "pre"), touched);
|
|
1003
|
+
const orgId = post.orgId ?? pre.orgId;
|
|
1004
|
+
if (!orgId || !ORG_ID_RE.test(orgId))
|
|
1005
|
+
return null;
|
|
1006
|
+
const window = await readJson(path.join(bundleDir, "mutation-window.json"));
|
|
1007
|
+
const attemptFile = await readJson(path.join(bundleDir, "attempt.json"));
|
|
1008
|
+
const scan = await readJson(path.join(bundleDir, "change-scan.json"));
|
|
1009
|
+
// <session>/direct/<wave> — the bundle's own location IS its identity, which is what makes the
|
|
1010
|
+
// sort key derivable from a foreign parcel that carries no session record with it.
|
|
1011
|
+
const waveLabel = path.basename(bundleDir);
|
|
1012
|
+
const sessionId = path.basename(path.resolve(bundleDir, "..", ".."));
|
|
1013
|
+
const preByKey = new Map(pre.components.map((c) => [componentKey(c), c]));
|
|
1014
|
+
const removed = [];
|
|
1015
|
+
for (const del of request?.deletes ?? []) {
|
|
1016
|
+
const previous = preByKey.get(componentKey(del));
|
|
1017
|
+
if (previous)
|
|
1018
|
+
removed.push({ typeName: del.typeName, fullName: del.fullName, file: previous.file });
|
|
1019
|
+
}
|
|
1020
|
+
const wave = {
|
|
1021
|
+
orgId,
|
|
1022
|
+
sessionId,
|
|
1023
|
+
wave: waveLabel,
|
|
1024
|
+
attempt: attemptFile?.attempt ?? 1,
|
|
1025
|
+
verdict,
|
|
1026
|
+
windowStart: window?.start ?? EPOCH,
|
|
1027
|
+
...(scan?.windowSuspect ? { windowSuspect: true } : {}),
|
|
1028
|
+
...(verdict === "AMBIGUOUS_DELIVERY" ? { ambiguous: true } : {}),
|
|
1029
|
+
...(request?.batchId ? { batchId: request.batchId } : {}),
|
|
1030
|
+
...(request?.requirementIds?.length ? { requirementIds: request.requirementIds } : {}),
|
|
1031
|
+
...(request?.operator ? { operator: request.operator } : {}),
|
|
1032
|
+
...(request?.revertOf ? { revertOf: request.revertOf } : {}),
|
|
1033
|
+
components: post.components,
|
|
1034
|
+
sourceFiles: await readTree(path.join(bundleDir, "deployed")),
|
|
1035
|
+
removed
|
|
1036
|
+
};
|
|
1037
|
+
return { wave, preComponents: pre.components };
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The total sort key (R5): mutation-window start, then session id, then wave label, then attempt.
|
|
1041
|
+
*
|
|
1042
|
+
* ATTEMPT IS THE LAST TERM AND IT IS LOAD-BEARING. Two attempts at one wave are two entries (see
|
|
1043
|
+
* `waveIdentity`), and a retry whose mutation window is indistinguishable from the attempt before it
|
|
1044
|
+
* — a re-run seconds later, or one whose window could not be narrowed — would otherwise be ordered
|
|
1045
|
+
* by nothing at all, which is how two machines holding the same pair could commit them in opposite
|
|
1046
|
+
* orders. With attempt on the end the pair is ordered by construction, ascending, on every machine.
|
|
1047
|
+
*
|
|
1048
|
+
* It approximates org chronology only within clock skew and never claims more — a `windowSuspect`
|
|
1049
|
+
* wave says so in its own trailer. What it DOES guarantee is totality: the same bundle set orders
|
|
1050
|
+
* the same way on every machine, in any arrival order, which is what makes a foreign parcel
|
|
1051
|
+
* insertable by rebuilding rather than merging.
|
|
1052
|
+
*
|
|
1053
|
+
* If `deploy.json`'s raw result is later found to carry Salesforce's server-stamped completion
|
|
1054
|
+
* time, that becomes the primary term and this comment is the place it goes. Checked at U2: the
|
|
1055
|
+
* parsed deploy result exposes `status` and `id`; no completion timestamp is surfaced through
|
|
1056
|
+
* `parseDeployOutput`, so the mutation window remains the anchor.
|
|
1057
|
+
*
|
|
1058
|
+
* NUL joins the terms because it cannot occur in any of them, so no term can bleed into the next.
|
|
1059
|
+
* Written as the ESCAPE rather than as a literal NUL byte: a raw one in the source makes git
|
|
1060
|
+
* classify the whole file as binary — it sniffs the first 8000 bytes, and `projection/diff.ts`
|
|
1061
|
+
* already renders as `Bin` in commit diffs for exactly this reason — which silently costs every
|
|
1062
|
+
* future reviewer the diff. Identical string at runtime, identical ordering.
|
|
1063
|
+
*/
|
|
1064
|
+
function sortKey(wave) {
|
|
1065
|
+
return [wave.windowStart, wave.sessionId, wave.wave, String(wave.attempt)].join("\u0000");
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Every evidence bundle this install holds, as `<session>/direct/<wave>` directories.
|
|
1069
|
+
*
|
|
1070
|
+
* The bundle set IS the input to a rebuild, and it is spread across sessions rather than kept in a
|
|
1071
|
+
* list, so enumerating it is the one step between "the materializer changed" and "history reflects
|
|
1072
|
+
* the change". Bundles that belong to another org, or that never changed one, are `rebuild`'s to
|
|
1073
|
+
* skip — this only finds them.
|
|
1074
|
+
*/
|
|
1075
|
+
export async function workspaceBundleDirs(workspaceRoot = WORKSPACE_ROOT) {
|
|
1076
|
+
const sessions = await fs.readdir(workspaceRoot, { withFileTypes: true }).catch(() => []);
|
|
1077
|
+
const dirs = [];
|
|
1078
|
+
for (const session of sessions.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1079
|
+
const directDir = path.join(workspaceRoot, session.name, "direct");
|
|
1080
|
+
const waves = await fs.readdir(directDir, { withFileTypes: true }).catch(() => []);
|
|
1081
|
+
for (const wave of waves.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1082
|
+
dirs.push(path.join(directDir, wave.name));
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return dirs;
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* ONE WAVE, wherever its bundle came from — the dedupe key for a united set (U8).
|
|
1089
|
+
*
|
|
1090
|
+
* Not the sort key: `windowStart` leads that one so history orders by org chronology, and two copies
|
|
1091
|
+
* of one wave agree about it anyway. What identifies a wave is (session, label, attempt), and a
|
|
1092
|
+
* retry is deliberately a DIFFERENT wave here for the same reason it is a different commit (U2's
|
|
1093
|
+
* KTD): amending would rewrite history, so attempt 2 is its own entry, not a correction of attempt 1.
|
|
1094
|
+
*
|
|
1095
|
+
* NUL joins the terms, written as the ESCAPE for the reason `sortKey` gives: it cannot occur in any
|
|
1096
|
+
* term, so no term can bleed into the next and collapse two distinct waves into one.
|
|
1097
|
+
*/
|
|
1098
|
+
const waveIdentity = (wave) => [wave.sessionId, wave.wave, String(wave.attempt)].join("\u0000");
|
|
1099
|
+
function scopeOf(args) {
|
|
1100
|
+
const { local, foreign, duplicates, unreadable, foreignSet } = args;
|
|
1101
|
+
// WAVES, not bundles: a bundle whose verdict never changed the org contributes nothing to
|
|
1102
|
+
// history, and counting it here would overstate what this mirror covers.
|
|
1103
|
+
const held = `${local} wave(s) of its own`;
|
|
1104
|
+
if (!foreignSet || foreignSet.unavailable !== undefined) {
|
|
1105
|
+
const why = !foreignSet
|
|
1106
|
+
? "no foreign parcels were fetched"
|
|
1107
|
+
: `foreign parcels could NOT be fetched (${foreignSet.unavailable})`;
|
|
1108
|
+
return {
|
|
1109
|
+
kind: "local-only",
|
|
1110
|
+
// The second sentence is the point of the whole note: absence in this mirror has always meant
|
|
1111
|
+
// UNKNOWN, and a local-only rebuild widens what is unknown without changing what it means.
|
|
1112
|
+
note: `LOCAL ONLY — ${why}, so this history is built from this machine's ${held}. ` +
|
|
1113
|
+
`Waves run on another machine are absent from it, and that absence is not evidence that none exist.`
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const parts = [`united ${held} with ${foreign} wave(s) from parcels fetched for this org`];
|
|
1117
|
+
if (duplicates > 0)
|
|
1118
|
+
parts.push(`${duplicates} wave(s) arrived twice and were replayed once`);
|
|
1119
|
+
if (foreignSet.missing) {
|
|
1120
|
+
parts.push(`${foreignSet.missing} listed parcel(s) could NOT be staged, so waves may still be missing`);
|
|
1121
|
+
}
|
|
1122
|
+
// Staged and unreadable is a THIRD state, distinct from both of the above: the parcel arrived
|
|
1123
|
+
// whole and this build could not make a wave of it. Said in its own words because the operator's
|
|
1124
|
+
// next move differs — a staging failure is usually the network, an unreadable parcel is a bundle
|
|
1125
|
+
// this install does not understand, and the fix for that one is a version, not a retry.
|
|
1126
|
+
if (unreadable > 0) {
|
|
1127
|
+
parts.push(`${unreadable} fetched parcel(s) could NOT be read as a wave, so waves may still be missing`);
|
|
1128
|
+
}
|
|
1129
|
+
return {
|
|
1130
|
+
kind: "united",
|
|
1131
|
+
// The "nothing to add" sentence is a claim about the LEDGER's contents, so every way the fetch
|
|
1132
|
+
// could have held something this build failed to see has to disqualify it — including the case
|
|
1133
|
+
// where every parcel arrived and none of them parsed, which reaches here as `foreign: 0` and
|
|
1134
|
+
// would otherwise read as an empty ledger.
|
|
1135
|
+
note: `${parts.join("; ")}. ` +
|
|
1136
|
+
(foreign === 0 && duplicates === 0 && !foreignSet.missing && unreadable === 0
|
|
1137
|
+
? "The ledger holds nothing for this org that this machine did not already have. "
|
|
1138
|
+
: "") +
|
|
1139
|
+
"Any machine replaying this same set materializes these same commit ids."
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* Re-materialize an org's mirror from a bundle set — this machine's, another machine's, or both.
|
|
1144
|
+
*
|
|
1145
|
+
* DESTRUCTIVE by design and safe for the same reason: the mirror holds nothing the bundles do not,
|
|
1146
|
+
* so rebuilding is how a newly arrived foreign bundle takes its correct place in history without a
|
|
1147
|
+
* merge (R6). Every SHA changes; nothing may persist one (origin R17's alias rule).
|
|
1148
|
+
*
|
|
1149
|
+
* CONVERGENCE IS BY CONSTRUCTION, NOT BY EFFORT (AE3). Nothing here reconciles anything: the sort
|
|
1150
|
+
* key is total, the materializer is deterministic, and the rebuild is from scratch — so a foreign
|
|
1151
|
+
* wave whose mutation window predates every local commit simply sorts first and is committed first,
|
|
1152
|
+
* exactly as it would have been on the machine that ran it. Two installs given the same set in
|
|
1153
|
+
* opposite orders produce byte-identical history because neither install's ARRIVAL ORDER is an
|
|
1154
|
+
* input. The only thing this function adds over the local-only case is the union and the dedupe.
|
|
1155
|
+
*
|
|
1156
|
+
* LOCAL WINS A TIE, and it is a tie about bytes rather than about authority: a parcel is a tar of a
|
|
1157
|
+
* bundle directory, so this machine's copy and the copy it uploaded are the same artifact. Ordering
|
|
1158
|
+
* local first means a machine holding both replays its own file, and a machine holding only the
|
|
1159
|
+
* parcel replays the parcel — the same wave either way, which is what keeps the dedupe from being a
|
|
1160
|
+
* second thing two machines could disagree about.
|
|
1161
|
+
*
|
|
1162
|
+
* A MATERIALIZER MISMATCH IS WHY THIS IS ALWAYS A FULL REBUILD. A mirror carrying a commit from
|
|
1163
|
+
* another version of this file cannot have a differently-shaped commit appended onto it and still
|
|
1164
|
+
* be one history; the versions are read off the OLD mirror before it is replaced and reported, so
|
|
1165
|
+
* the answer to "why did every SHA move" is in the result rather than in a guess.
|
|
1166
|
+
*
|
|
1167
|
+
* It is also the REPAIR PATH when this file's reading of a bundle changes: a mirror materialized by
|
|
1168
|
+
* an older rule keeps whatever that rule let in until something replays the bundles. `in2
|
|
1169
|
+
* rebuild-mirror <orgId>` is that something.
|
|
1170
|
+
*/
|
|
1171
|
+
export async function rebuildFromSets(orgId, localBundleDirs, foreignSet, opts = {}) {
|
|
1172
|
+
assertOrgId(orgId);
|
|
1173
|
+
// Read BEFORE the mirror is removed — afterwards there is nothing left to have carried a trailer.
|
|
1174
|
+
const priorMaterializers = await mirrorMaterializerVersions(orgId, opts.root);
|
|
1175
|
+
const materializerMismatch = priorMaterializers.some((version) => version !== MATERIALIZER_VERSION);
|
|
1176
|
+
const sources = [
|
|
1177
|
+
...localBundleDirs.map((dir) => ({ dir, origin: "local" })),
|
|
1178
|
+
...(foreignSet?.dirs ?? []).map((dir) => ({ dir, origin: "foreign" }))
|
|
1179
|
+
];
|
|
1180
|
+
const chosen = new Map();
|
|
1181
|
+
let skipped = 0;
|
|
1182
|
+
let duplicates = 0;
|
|
1183
|
+
let unreadable = 0;
|
|
1184
|
+
for (const source of sources) {
|
|
1185
|
+
const read = await readWaveFromBundle(source.dir);
|
|
1186
|
+
if (!read) {
|
|
1187
|
+
// A fetched parcel that yields no wave is a hole in coverage, not a bundle correctly passed
|
|
1188
|
+
// over — see `RebuildResult.unreadable`. A local one is this machine's own and stays a skip.
|
|
1189
|
+
if (source.origin === "foreign")
|
|
1190
|
+
unreadable += 1;
|
|
1191
|
+
else
|
|
1192
|
+
skipped += 1;
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
if (read.wave.orgId.slice(0, 15) !== orgId.slice(0, 15)) {
|
|
1196
|
+
skipped += 1;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
// Replay under the mirror's own key, so a 15-char id in an older bundle cannot fork the
|
|
1200
|
+
// history of an org already mirrored under its 18-char form.
|
|
1201
|
+
read.wave.orgId = orgId;
|
|
1202
|
+
const identity = waveIdentity(read.wave);
|
|
1203
|
+
if (chosen.has(identity)) {
|
|
1204
|
+
duplicates += 1;
|
|
1205
|
+
continue;
|
|
1206
|
+
}
|
|
1207
|
+
chosen.set(identity, { ...read, origin: source.origin });
|
|
1208
|
+
}
|
|
1209
|
+
const parsed = [...chosen.values()];
|
|
1210
|
+
parsed.sort((a, b) => (sortKey(a.wave) < sortKey(b.wave) ? -1 : sortKey(a.wave) > sortKey(b.wave) ? 1 : 0));
|
|
1211
|
+
const local = parsed.filter((entry) => entry.origin === "local").length;
|
|
1212
|
+
const foreign = parsed.length - local;
|
|
1213
|
+
return withOrgLock(orgId, opts, async () => {
|
|
1214
|
+
await fs.rm(mirrorPath(orgId, opts.root), { recursive: true, force: true });
|
|
1215
|
+
await ensureRepo(orgId, opts);
|
|
1216
|
+
let commits = 0;
|
|
1217
|
+
for (const entry of parsed) {
|
|
1218
|
+
const result = await overlayAndCommitLocked({ ...opts, wave: entry.wave, preComponents: entry.preComponents });
|
|
1219
|
+
if (result.committed)
|
|
1220
|
+
commits += 1;
|
|
1221
|
+
}
|
|
1222
|
+
const head = commits > 0 ? (await gitOrThrow(mirrorPath(orgId, opts.root), ["rev-parse", "HEAD"])).trim() : null;
|
|
1223
|
+
return {
|
|
1224
|
+
orgId,
|
|
1225
|
+
commits,
|
|
1226
|
+
head,
|
|
1227
|
+
skipped,
|
|
1228
|
+
unreadable,
|
|
1229
|
+
duplicates,
|
|
1230
|
+
local,
|
|
1231
|
+
foreign,
|
|
1232
|
+
scope: scopeOf({ local, foreign, duplicates, unreadable, ...(foreignSet ? { foreignSet } : {}) }),
|
|
1233
|
+
priorMaterializers,
|
|
1234
|
+
materializerMismatch
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* Rebuild from this machine's bundles alone.
|
|
1240
|
+
*
|
|
1241
|
+
* Kept as its own name because it is the common call and because the scope it produces is a real,
|
|
1242
|
+
* different claim — `local-only`, said out loud — rather than a united rebuild that happened to find
|
|
1243
|
+
* nothing foreign.
|
|
1244
|
+
*/
|
|
1245
|
+
export async function rebuild(orgId, bundleDirs, opts = {}) {
|
|
1246
|
+
return rebuildFromSets(orgId, bundleDirs, undefined, opts);
|
|
1247
|
+
}
|
|
1248
|
+
// ASCII record/unit separators. A commit body is multi-line free text, so newline-delimited
|
|
1249
|
+
// parsing of `git log` output cannot tell where one commit ends — these cannot occur inside a
|
|
1250
|
+
// message, because `trailerValue` strips every control character before one is written.
|
|
1251
|
+
const RECORD = "\u001e";
|
|
1252
|
+
const FIELD = "\u001f";
|
|
1253
|
+
function parseTrailers(body) {
|
|
1254
|
+
const out = new Map();
|
|
1255
|
+
for (const line of body.split("\n")) {
|
|
1256
|
+
const match = /^([A-Za-z][A-Za-z0-9-]*): (.*)$/.exec(line.trim());
|
|
1257
|
+
if (!match)
|
|
1258
|
+
continue;
|
|
1259
|
+
const key = match[1].toLowerCase();
|
|
1260
|
+
out.set(key, [...(out.get(key) ?? []), match[2]]);
|
|
1261
|
+
}
|
|
1262
|
+
return out;
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Pathspecs matching one component's files, from a `Type:FullName` key.
|
|
1266
|
+
*
|
|
1267
|
+
* A glob rather than an exact path on purpose. The projection decides where a component's snapshot
|
|
1268
|
+
* lands (`objects/<obj>/fields/<field>.json`, `components/<Type>/<name>.json`, …) and a caller of a
|
|
1269
|
+
* READ tool has no reason to know that layout; reproducing it exactly here would also be a second
|
|
1270
|
+
* copy of a mapping that lives in `projection/project.ts` and would drift from it silently. The
|
|
1271
|
+
* globs are still narrow enough that `Account.Status__c` cannot match `Contact.Status__c`.
|
|
1272
|
+
*/
|
|
1273
|
+
function componentPathspecs(component) {
|
|
1274
|
+
const [typeName, ...rest] = component.split(":");
|
|
1275
|
+
const fullName = rest.join(":");
|
|
1276
|
+
if (!typeName || !fullName) {
|
|
1277
|
+
throw new OrgRepoError("INVALID_REF", `component must be "Type:FullName", got ${JSON.stringify(component)}`);
|
|
1278
|
+
}
|
|
1279
|
+
// The projection's own file-name sanitizer, mirrored: anything outside this set becomes "_".
|
|
1280
|
+
const safe = (value) => value.replace(/[^A-Za-z0-9_.-]/g, "_");
|
|
1281
|
+
if (typeName === "CustomObject")
|
|
1282
|
+
return [`:(glob,top)*/objects/${safe(fullName)}/object.*`];
|
|
1283
|
+
if (fullName.includes(".")) {
|
|
1284
|
+
const object = fullName.slice(0, fullName.indexOf("."));
|
|
1285
|
+
const leaf = fullName.slice(fullName.indexOf(".") + 1);
|
|
1286
|
+
return [
|
|
1287
|
+
`:(glob,top)*/objects/${safe(object)}/*/${safe(leaf)}.*`,
|
|
1288
|
+
`:(glob,top)*/objects/${safe(object)}/*/*/${safe(leaf)}.*`
|
|
1289
|
+
];
|
|
1290
|
+
}
|
|
1291
|
+
return [`:(glob,top)*/**/${safe(fullName)}.*`];
|
|
1292
|
+
}
|
|
1293
|
+
async function repoIfPresent(orgId, root) {
|
|
1294
|
+
const dir = mirrorPath(orgId, root);
|
|
1295
|
+
return (await fs
|
|
1296
|
+
.stat(path.join(dir, ".git"))
|
|
1297
|
+
.then(() => true)
|
|
1298
|
+
.catch(() => false))
|
|
1299
|
+
? dir
|
|
1300
|
+
: null;
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* The mirror's commits, newest first — history readable without anyone touching git (R8).
|
|
1304
|
+
*
|
|
1305
|
+
* The root commit is excluded: it is a materialization artifact (an empty tree carrying the org
|
|
1306
|
+
* id), not something that happened to the org.
|
|
1307
|
+
*/
|
|
1308
|
+
export async function historyLog(args) {
|
|
1309
|
+
const dir = await repoIfPresent(args.orgId, args.root);
|
|
1310
|
+
if (!dir)
|
|
1311
|
+
return [];
|
|
1312
|
+
const gitArgs = [
|
|
1313
|
+
"log",
|
|
1314
|
+
`--max-count=${Math.min(Math.max(args.limit ?? 50, 1), 500)}`,
|
|
1315
|
+
`--format=${RECORD}%H${FIELD}%aI${FIELD}%s${FIELD}%b`
|
|
1316
|
+
];
|
|
1317
|
+
// `--follow` is deliberately absent: a rename in this tree means a component was RENAMED in the
|
|
1318
|
+
// org, which is a different component to Salesforce and should read as one here too.
|
|
1319
|
+
if (args.component)
|
|
1320
|
+
gitArgs.push("--", ...componentPathspecs(args.component));
|
|
1321
|
+
const out = await gitOrThrow(dir, gitArgs);
|
|
1322
|
+
const entries = [];
|
|
1323
|
+
for (const record of out.split(RECORD)) {
|
|
1324
|
+
if (!record.trim())
|
|
1325
|
+
continue;
|
|
1326
|
+
const [sha = "", date = "", subject = "", body = ""] = record.split(FIELD);
|
|
1327
|
+
const trailers = parseTrailers(body);
|
|
1328
|
+
if (subject.startsWith("mirror: "))
|
|
1329
|
+
continue; // the root commit
|
|
1330
|
+
entries.push({
|
|
1331
|
+
sha,
|
|
1332
|
+
date,
|
|
1333
|
+
subject,
|
|
1334
|
+
...(trailers.get("session-id")?.[0] ? { sessionId: trailers.get("session-id")?.[0] } : {}),
|
|
1335
|
+
...(trailers.get("wave")?.[0] ? { wave: trailers.get("wave")?.[0] } : {}),
|
|
1336
|
+
...(trailers.get("attempt")?.[0] ? { attempt: Number(trailers.get("attempt")?.[0]) } : {}),
|
|
1337
|
+
...(trailers.get("verdict")?.[0] ? { verdict: trailers.get("verdict")?.[0] } : {}),
|
|
1338
|
+
...(trailers.get("batch-id")?.[0] ? { batchId: trailers.get("batch-id")?.[0] } : {}),
|
|
1339
|
+
...(trailers.get("operator")?.[0] ? { operator: trailers.get("operator")?.[0] } : {}),
|
|
1340
|
+
...(trailers.get("materializer")?.[0] ? { materializer: trailers.get("materializer")?.[0] } : {}),
|
|
1341
|
+
...(trailers.get("revert-of")?.[0] ? { revertOf: trailers.get("revert-of")?.[0] } : {}),
|
|
1342
|
+
requirementIds: trailers.get("requirement-id") ?? [],
|
|
1343
|
+
drifted: trailers.get("drift") ?? [],
|
|
1344
|
+
driftExplained: trailers.get("drift-explained") ?? [],
|
|
1345
|
+
notComparable: trailers.get("drift-not-comparable") ?? [],
|
|
1346
|
+
incompleteFacets: trailers.get("incomplete-facet") ?? [],
|
|
1347
|
+
ambiguous: trailers.get("ambiguous-delivery")?.[0] === "true",
|
|
1348
|
+
orderingUnreliable: trailers.get("ordering-unreliable")?.[0] === "true"
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
return entries;
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Every distinct `Materializer:` an existing mirror's commits carry, sorted.
|
|
1355
|
+
*
|
|
1356
|
+
* THE DOCTRINE'S DETECTOR (U8): a mirror holding a commit from another version of this file cannot
|
|
1357
|
+
* have a differently-shaped commit appended onto it and still be one history — the serialization is
|
|
1358
|
+
* the one input determinism cannot pin, which is why it is stamped rather than pretended away. So a
|
|
1359
|
+
* mismatch means FULL REBUILD, and this is what a caller asks before deciding.
|
|
1360
|
+
*
|
|
1361
|
+
* The root commit carries one too, deliberately: a mirror initialized by an older version and never
|
|
1362
|
+
* written to since is still an older mirror, and answering "no versions" for it would let it be
|
|
1363
|
+
* appended to.
|
|
1364
|
+
*
|
|
1365
|
+
* Empty for an org with no mirror — nothing to disagree with is not a disagreement.
|
|
1366
|
+
*/
|
|
1367
|
+
export async function mirrorMaterializerVersions(orgId, root) {
|
|
1368
|
+
const dir = await repoIfPresent(orgId, root);
|
|
1369
|
+
if (!dir)
|
|
1370
|
+
return [];
|
|
1371
|
+
const out = await git(dir, ["log", `--format=${RECORD}%b`]);
|
|
1372
|
+
if (!out.ok)
|
|
1373
|
+
return [];
|
|
1374
|
+
const versions = new Set();
|
|
1375
|
+
for (const record of out.stdout.split(RECORD)) {
|
|
1376
|
+
if (!record.trim())
|
|
1377
|
+
continue;
|
|
1378
|
+
for (const value of parseTrailers(record).get("materializer") ?? [])
|
|
1379
|
+
versions.add(value.trim());
|
|
1380
|
+
}
|
|
1381
|
+
return [...versions].sort();
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Every component this org's history records as delivered, newest commit first.
|
|
1385
|
+
*
|
|
1386
|
+
* The contamination report (U5; origin R22) is the consumer: it needs the delivered SET per session,
|
|
1387
|
+
* which `historyLog` cannot give because trailers carry wave identity, not component identity.
|
|
1388
|
+
* Adding a `Component:` trailer would have moved every SHA in every existing mirror for a read-side
|
|
1389
|
+
* need, so this derives the same fact from the tree instead — a read helper, changing nothing about
|
|
1390
|
+
* materialization.
|
|
1391
|
+
*
|
|
1392
|
+
* IDENTITY COMES FROM THE SNAPSHOT, NEVER FROM THE PATH. The projection decides where a component's
|
|
1393
|
+
* file lands and sanitizes the name on the way (`projection/project.ts` `safeName` maps anything
|
|
1394
|
+
* outside `[A-Za-z0-9_.-]` to `_`), so inverting a path is a guess — and a guessed key in a
|
|
1395
|
+
* contamination report is a false accusation about somebody's run. The committed snapshot carries
|
|
1396
|
+
* `typeName` and `fullName` verbatim, so the blob is read and a file that does not name itself
|
|
1397
|
+
* contributes nothing rather than something plausible.
|
|
1398
|
+
*
|
|
1399
|
+
* Cost: one blob read per DISTINCT file, not per commit-file pair — a path names the same component
|
|
1400
|
+
* for as long as it exists, so the mapping is resolved once and reused. All of those reads are one
|
|
1401
|
+
* `git cat-file --batch` process (see `keysFromBlobs`), where this once launched a `git show` per
|
|
1402
|
+
* file and therefore one child process per component. Callers run this at batch end, off the
|
|
1403
|
+
* delivery path.
|
|
1404
|
+
*/
|
|
1405
|
+
export async function deliveredComponents(args) {
|
|
1406
|
+
const dir = await repoIfPresent(args.orgId, args.root);
|
|
1407
|
+
if (!dir)
|
|
1408
|
+
return [];
|
|
1409
|
+
const out = await gitOrThrow(dir, [
|
|
1410
|
+
"log",
|
|
1411
|
+
`--max-count=${Math.min(Math.max(args.limit ?? 200, 1), 2000)}`,
|
|
1412
|
+
"--name-status",
|
|
1413
|
+
// A rename in this tree means the component was RENAMED in the org, which is a DIFFERENT
|
|
1414
|
+
// component to Salesforce. Keeping the delete and the add separate says exactly that.
|
|
1415
|
+
"--no-renames",
|
|
1416
|
+
`--format=${RECORD}%H${FIELD}%aI${FIELD}%s${FIELD}%b${FIELD}`
|
|
1417
|
+
]);
|
|
1418
|
+
const pending = [];
|
|
1419
|
+
const blobRequests = [];
|
|
1420
|
+
const requestIndexByFile = new Map();
|
|
1421
|
+
for (const record of out.split(RECORD)) {
|
|
1422
|
+
if (!record.trim())
|
|
1423
|
+
continue;
|
|
1424
|
+
const [sha = "", date = "", subject = "", body = "", changes = ""] = record.split(FIELD);
|
|
1425
|
+
if (subject.startsWith("mirror: "))
|
|
1426
|
+
continue; // the root commit, a materialization artifact
|
|
1427
|
+
const trailers = parseTrailers(body);
|
|
1428
|
+
for (const line of changes.split("\n")) {
|
|
1429
|
+
// `source/` is deliberately not consulted: it is a DEPLOY LOG that keeps a deleted
|
|
1430
|
+
// component's last bytes, so it would report a removal as a live delivery.
|
|
1431
|
+
const match = /^([AMD])\t(projection\/.+)$/.exec(line);
|
|
1432
|
+
if (!match)
|
|
1433
|
+
continue;
|
|
1434
|
+
const removed = match[1] === "D";
|
|
1435
|
+
const file = match[2];
|
|
1436
|
+
if (!requestIndexByFile.has(file)) {
|
|
1437
|
+
requestIndexByFile.set(file, blobRequests.length);
|
|
1438
|
+
// The revision recorded is the FIRST one that mentioned this path — a delete reads its
|
|
1439
|
+
// parent, an add or modify reads the commit itself, which is the same choice `git show`
|
|
1440
|
+
// made per call.
|
|
1441
|
+
blobRequests.push({ file, sha, removed });
|
|
1442
|
+
}
|
|
1443
|
+
pending.push({ sha, date, removed, trailers, file });
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
const keys = await keysFromBlobs(dir, blobRequests);
|
|
1447
|
+
const delivered = [];
|
|
1448
|
+
for (const entry of pending) {
|
|
1449
|
+
const index = requestIndexByFile.get(entry.file);
|
|
1450
|
+
const key = index === undefined ? null : keys[index];
|
|
1451
|
+
if (!key)
|
|
1452
|
+
continue;
|
|
1453
|
+
delivered.push({
|
|
1454
|
+
key,
|
|
1455
|
+
sha: entry.sha,
|
|
1456
|
+
date: entry.date,
|
|
1457
|
+
removed: entry.removed,
|
|
1458
|
+
// `sessionId` absent means the commit predates the trailer or came from a foreign
|
|
1459
|
+
// materializer. Left undefined rather than defaulted: the consumer decides what an
|
|
1460
|
+
// unattributed delivery means, and no caller may read a default as an attribution.
|
|
1461
|
+
...(entry.trailers.get("session-id")?.[0] ? { sessionId: entry.trailers.get("session-id")?.[0] } : {}),
|
|
1462
|
+
...(entry.trailers.get("wave")?.[0] ? { wave: entry.trailers.get("wave")?.[0] } : {}),
|
|
1463
|
+
...(entry.trailers.get("verdict")?.[0] ? { verdict: entry.trailers.get("verdict")?.[0] } : {}),
|
|
1464
|
+
...(entry.trailers.get("batch-id")?.[0] ? { batchId: entry.trailers.get("batch-id")?.[0] } : {})
|
|
1465
|
+
});
|
|
1466
|
+
}
|
|
1467
|
+
return delivered;
|
|
1468
|
+
}
|
|
1469
|
+
/** `<key> <> <facet, facet>` trailer values, back into the pair they encode. */
|
|
1470
|
+
function parseIncompleteFacets(values) {
|
|
1471
|
+
const out = [];
|
|
1472
|
+
for (const value of values) {
|
|
1473
|
+
const [component = "", facets = ""] = value.split(" <> ");
|
|
1474
|
+
if (!component.trim() || !facets.trim())
|
|
1475
|
+
continue;
|
|
1476
|
+
out.push({
|
|
1477
|
+
component: component.trim(),
|
|
1478
|
+
facets: facets
|
|
1479
|
+
.split(",")
|
|
1480
|
+
.map((f) => f.trim())
|
|
1481
|
+
.filter(Boolean)
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
return out;
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* The facets one commit's post-image could not read — for a caller that has a ref and wants the
|
|
1488
|
+
* bound without re-deriving it from trailers.
|
|
1489
|
+
*/
|
|
1490
|
+
export async function historyIncompleteFacets(args) {
|
|
1491
|
+
const ref = assertRef(args.ref);
|
|
1492
|
+
const dir = await repoIfPresent(args.orgId, args.root);
|
|
1493
|
+
if (!dir)
|
|
1494
|
+
return [];
|
|
1495
|
+
const body = await git(dir, ["log", "-1", "--format=%b", ref]);
|
|
1496
|
+
if (!body.ok)
|
|
1497
|
+
return [];
|
|
1498
|
+
return parseIncompleteFacets(parseTrailers(body.stdout).get("incomplete-facet") ?? []);
|
|
1499
|
+
}
|
|
1500
|
+
export async function historyShow(args) {
|
|
1501
|
+
const ref = assertRef(args.ref);
|
|
1502
|
+
const dir = await repoIfPresent(args.orgId, args.root);
|
|
1503
|
+
if (!dir)
|
|
1504
|
+
return { sha: "", message: "", files: [], incompleteFacets: [] };
|
|
1505
|
+
const sha = (await gitOrThrow(dir, ["rev-parse", `${ref}^{commit}`])).trim();
|
|
1506
|
+
const message = await gitOrThrow(dir, ["log", "-1", "--format=%B", sha]);
|
|
1507
|
+
const names = await gitOrThrow(dir, ["show", "--name-status", "--format=", sha]);
|
|
1508
|
+
const files = names
|
|
1509
|
+
.split("\n")
|
|
1510
|
+
.map((line) => line.trim())
|
|
1511
|
+
.filter(Boolean)
|
|
1512
|
+
.map((line) => {
|
|
1513
|
+
const [status = "", filePath = ""] = line.split("\t");
|
|
1514
|
+
return { status, path: filePath };
|
|
1515
|
+
});
|
|
1516
|
+
// Parsed from the message already in hand rather than by a second git call.
|
|
1517
|
+
const incompleteFacets = parseIncompleteFacets(parseTrailers(message).get("incomplete-facet") ?? []);
|
|
1518
|
+
return { sha, message, files, incompleteFacets };
|
|
1519
|
+
}
|
|
1520
|
+
export async function historyDiff(args) {
|
|
1521
|
+
const ref = assertRef(args.ref);
|
|
1522
|
+
const dir = await repoIfPresent(args.orgId, args.root);
|
|
1523
|
+
if (!dir)
|
|
1524
|
+
return "";
|
|
1525
|
+
const gitArgs = args.against
|
|
1526
|
+
? ["diff", assertRef(args.against), ref]
|
|
1527
|
+
: ["show", "--format=", "--patch", ref];
|
|
1528
|
+
if (args.component)
|
|
1529
|
+
gitArgs.push("--", ...componentPathspecs(args.component));
|
|
1530
|
+
return gitOrThrow(dir, gitArgs);
|
|
1531
|
+
}
|