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,179 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
// SCHEMA TIER — the official Metadata API definitions, as runtime data.
|
|
3
|
+
//
|
|
4
|
+
// Built by scripts/build-metadata-schema.mjs from @salesforce/types. See that file for why the
|
|
5
|
+
// extraction exists at all (TypeScript types are erased, so they cannot be queried at runtime).
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS TIER IS FOR: killing the guess -> 45s checkOnly -> rejected -> guess again loop. A
|
|
8
|
+
// deploy is all-or-nothing and checkOnly reports only the FIRST failure, so one bad element rolls
|
|
9
|
+
// back an entire wave and a wave with three bad elements costs three serial round-trips. This
|
|
10
|
+
// answers the same questions locally, all at once, in microseconds.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT DELIBERATELY DOES NOT DO: it checks SHAPE, never truth-in-this-org. `emailTemplate`
|
|
13
|
+
// naming a template that does not exist, or `componentName` being `forceSearch:recentItems`
|
|
14
|
+
// (a free xsd:string), are both schema-VALID. Those belong to the referential tier and to
|
|
15
|
+
// observing the org. Claiming otherwise would mean inventing constraints Salesforce does not have.
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
let cached = null;
|
|
18
|
+
function schema() {
|
|
19
|
+
if (!cached)
|
|
20
|
+
cached = require("./metadataSchema.generated.json");
|
|
21
|
+
return cached;
|
|
22
|
+
}
|
|
23
|
+
export function schemaVersion() {
|
|
24
|
+
return `${schema().sourcePackage}@${schema().sourceVersion}`;
|
|
25
|
+
}
|
|
26
|
+
/** True when the artifact describes this metadata type at all. */
|
|
27
|
+
export function schemaKnowsType(typeName) {
|
|
28
|
+
return Boolean(schema().types[typeName]);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Element name -> the types that legitimately declare it.
|
|
32
|
+
*
|
|
33
|
+
* This is what makes a placement check safe. An element name that is simply UNKNOWN stays silent —
|
|
34
|
+
* the artifact is one API version and "I have not heard of this" is not evidence of wrong. But an
|
|
35
|
+
* element the schema knows perfectly well, appearing somewhere it is not declared, is a different
|
|
36
|
+
* claim entirely: it is in the wrong place, and we can say where it belongs.
|
|
37
|
+
*
|
|
38
|
+
* Live origin: both flows put <triggerOrder> inside <start>. It is a member of Flow, not FlowStart.
|
|
39
|
+
* Schema-shaped, locally knowable, and it still cost a 45s checkOnly that rolled back the wave.
|
|
40
|
+
*/
|
|
41
|
+
let ownersCache = null;
|
|
42
|
+
function declaringTypes() {
|
|
43
|
+
if (ownersCache)
|
|
44
|
+
return ownersCache;
|
|
45
|
+
const owners = new Map();
|
|
46
|
+
for (const [typeName, members] of Object.entries(schema().types)) {
|
|
47
|
+
for (const member of Object.keys(members)) {
|
|
48
|
+
const list = owners.get(member) ?? [];
|
|
49
|
+
list.push(typeName);
|
|
50
|
+
owners.set(member, list);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ownersCache = owners;
|
|
54
|
+
return owners;
|
|
55
|
+
}
|
|
56
|
+
const MAX_FINDINGS = 25;
|
|
57
|
+
/**
|
|
58
|
+
* Walk an authored XML body against its type definition.
|
|
59
|
+
*
|
|
60
|
+
* Only two checks, and both are chosen for a near-zero false-positive rate:
|
|
61
|
+
*
|
|
62
|
+
* - ENUM: an enum's value set is genuinely exhaustive at a given API version, which is the only
|
|
63
|
+
* reason a wrong value is knowable locally. This is the NextOwnerType/'group' case.
|
|
64
|
+
* - REQUIRED: a member the definitions mark non-optional that the author omitted. Salesforce
|
|
65
|
+
* would reject the deploy for it.
|
|
66
|
+
*
|
|
67
|
+
* Element NAMES are deliberately NOT checked. That check can only ever warn (a newer org may have
|
|
68
|
+
* elements this artifact version does not know), and it is the largest false-positive surface —
|
|
69
|
+
* exactly the noise that trains a reader to ignore findings.
|
|
70
|
+
*
|
|
71
|
+
* Unknown types and unknown member types are skipped silently rather than warned about: the
|
|
72
|
+
* artifact is a snapshot of one API version, and "I have not heard of this" is not evidence of
|
|
73
|
+
* "this is wrong".
|
|
74
|
+
*/
|
|
75
|
+
export function checkAgainstSchema(typeName, body) {
|
|
76
|
+
const s = schema();
|
|
77
|
+
if (!s.types[typeName])
|
|
78
|
+
return [];
|
|
79
|
+
const findings = [];
|
|
80
|
+
const walk = (members, node, prefix) => {
|
|
81
|
+
if (findings.length >= MAX_FINDINGS)
|
|
82
|
+
return;
|
|
83
|
+
// REQUIRED: present in the definitions as non-optional, absent from the authored XML.
|
|
84
|
+
for (const [name, def] of Object.entries(members)) {
|
|
85
|
+
if (!def.required)
|
|
86
|
+
continue;
|
|
87
|
+
// NEVER required-check a COLLECTION. In TypeScript `fields: CustomField[]` is non-optional
|
|
88
|
+
// because an empty array is representable; in XML an empty collection is simply ABSENT. So
|
|
89
|
+
// every unstated collection reads as "required and missing". Measured on 160 components the
|
|
90
|
+
// org actually accepted, this single confusion produced 819 false findings (5 per component)
|
|
91
|
+
// — noise that would have trained every reader to ignore the tier. Only scalars and single
|
|
92
|
+
// objects can be meaningfully "missing".
|
|
93
|
+
if (def.array)
|
|
94
|
+
continue;
|
|
95
|
+
// NEVER required-check a BOOLEAN either. Salesforce defaults an omitted boolean to false, so
|
|
96
|
+
// absence is a legal way to say false — `<default>` on a picklist value, `<sorted>` on a
|
|
97
|
+
// value set, `<includeRecordsOwnedByAll>` on a sharing rule. Measured: these were the ONLY
|
|
98
|
+
// findings left on org-accepted components after collections were excluded, all 13 of them.
|
|
99
|
+
if (def.type === "boolean")
|
|
100
|
+
continue;
|
|
101
|
+
// `fullName` rides the file path in source format, so its absence from the body is normal.
|
|
102
|
+
//
|
|
103
|
+
// A NESTED member does need its own — but this tier cannot say so: @salesforce/types declares
|
|
104
|
+
// `Metadata = { fullName?: string }`, optional, and the same optional flows to every child. The
|
|
105
|
+
// types have no way to express "optional at the root, required on a child". Attempting it here
|
|
106
|
+
// would be inventing a constraint the artifact does not carry. It lives in the LEMMA tier
|
|
107
|
+
// instead, where live-caught facts belong (LEMMA_CHILD_MISSING_FULLNAME).
|
|
108
|
+
if (name === "fullName")
|
|
109
|
+
continue;
|
|
110
|
+
const value = node[name];
|
|
111
|
+
if (value === undefined || value === null || value === "") {
|
|
112
|
+
findings.push({
|
|
113
|
+
path: prefix ? `${prefix}.${name}` : name,
|
|
114
|
+
kind: "required",
|
|
115
|
+
message: `<${name}> is required on ${typeName}${prefix ? ` (at ${prefix})` : ""} but is missing.`
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// ENUM + recursion into nested object types.
|
|
120
|
+
for (const [name, raw] of Object.entries(node)) {
|
|
121
|
+
const def = members[name];
|
|
122
|
+
if (!def) {
|
|
123
|
+
// Unknown HERE. Silent unless the schema declares it somewhere else, in which case this is
|
|
124
|
+
// a misplacement rather than an unrecognised name — see declaringTypes().
|
|
125
|
+
// `fullName` USED to be excluded outright. The reason was real but is now obsolete: the
|
|
126
|
+
// artifact modelled it inconsistently (CompactLayout declared it, ValidationRule did not),
|
|
127
|
+
// so a placement check reported <fullName> inside <validationRules> as misplaced and
|
|
128
|
+
// suggested DeployMessage — a confident, wrong answer on a shape the org accepts daily.
|
|
129
|
+
// The extractor now follows TypeReferences through intersections, so fullName resolves on
|
|
130
|
+
// 750 types instead of 14 and the inconsistency is gone.
|
|
131
|
+
//
|
|
132
|
+
// That makes a NARROW claim safe, and it is one the org enforces: a child type that
|
|
133
|
+
// declares `name` and NOT `fullName` is name-keyed, so <fullName> there is simply the wrong
|
|
134
|
+
// key. 221 types are in that class; the 34 that declare BOTH stay silent, because there the
|
|
135
|
+
// schema genuinely cannot tell us which one is meant. Live cost of not having this: an
|
|
136
|
+
// <fullName> inside <approvalStep> failed CF77's wave 4 at checkOnly and — a deploy being
|
|
137
|
+
// all-or-nothing — rolled back six correct components with it.
|
|
138
|
+
const elsewhere = name === "fullName" ? (members.name && !members.fullName ? ["<name>"] : undefined) : declaringTypes().get(name);
|
|
139
|
+
if (elsewhere && elsewhere.length > 0 && findings.length < MAX_FINDINGS) {
|
|
140
|
+
findings.push({
|
|
141
|
+
path: prefix ? `${prefix}.${name}` : name,
|
|
142
|
+
kind: "placement",
|
|
143
|
+
message: name === "fullName"
|
|
144
|
+
? `<fullName> is not valid ${prefix ? `inside <${prefix.split(".").pop()}>` : `on ${typeName}`} — this child is keyed by <name>, not <fullName>. Rename the element, and make its value a developer name (letters, digits and underscores only; no spaces).`
|
|
145
|
+
: `<${name}> is not valid ${prefix ? `inside <${prefix.split(".").pop()}>` : `on ${typeName}`}. The schema declares it on: ${elsewhere.slice(0, 4).join(", ")}${elsewhere.length > 4 ? ", …" : ""}. Move it there.`
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const path = prefix ? `${prefix}.${name}` : name;
|
|
151
|
+
const values = Array.isArray(raw) ? raw : [raw];
|
|
152
|
+
const allowed = s.enums[def.type];
|
|
153
|
+
if (allowed) {
|
|
154
|
+
for (const v of values) {
|
|
155
|
+
if (v === undefined || v === null || typeof v === "object")
|
|
156
|
+
continue;
|
|
157
|
+
if (!allowed.includes(String(v))) {
|
|
158
|
+
findings.push({
|
|
159
|
+
path,
|
|
160
|
+
kind: "enum",
|
|
161
|
+
message: `<${name}> = "${String(v)}" is not a valid ${def.type}. Allowed: ${allowed.join(" | ")}.`
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const nested = s.types[def.type];
|
|
168
|
+
if (nested) {
|
|
169
|
+
for (const v of values) {
|
|
170
|
+
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
171
|
+
walk(nested, v, path);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
walk(s.types[typeName], body, "");
|
|
178
|
+
return findings;
|
|
179
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// THE OPERATOR'S RESULTS PAGE, RENDERED.
|
|
2
|
+
//
|
|
3
|
+
// Before this module, the page was composed freehand by the supervisor from a prose skeleton on
|
|
4
|
+
// every run — which is why it never displayed the same way twice, and why 7 outstanding items
|
|
5
|
+
// could outweigh 45 delivered ones on the reader's screen. The counts were already computed once
|
|
6
|
+
// and carried to close (`plannedScope.ts`); what was missing was a renderer that spends the
|
|
7
|
+
// PAGE'S SPACE in proportion to what actually happened.
|
|
8
|
+
//
|
|
9
|
+
// Pure by construction: no I/O, no clock, no org call. Every input arrives as an argument so the
|
|
10
|
+
// whole page is a golden test.
|
|
11
|
+
import { CI_RULES } from "../campfire/configItem.js";
|
|
12
|
+
import { plannedKey } from "./plannedScope.js";
|
|
13
|
+
/**
|
|
14
|
+
* The nouns de-camel-casing gets WRONG. Everything else falls through to the split below, which
|
|
15
|
+
* turns "ValidationRule" into "validation rule" perfectly well — so this map stays short rather
|
|
16
|
+
* than becoming a second registry to maintain.
|
|
17
|
+
*/
|
|
18
|
+
const TYPE_NOUN = {
|
|
19
|
+
CustomObject: "object",
|
|
20
|
+
CustomField: "field",
|
|
21
|
+
StandardField: "field",
|
|
22
|
+
// A WebLink IS a custom button in Setup. "web link" would send someone hunting for the wrong page.
|
|
23
|
+
WebLink: "button",
|
|
24
|
+
FlexiPage: "page",
|
|
25
|
+
Layout: "page layout",
|
|
26
|
+
CustomApplication: "app",
|
|
27
|
+
CustomTab: "tab",
|
|
28
|
+
ApexClass: "Apex class",
|
|
29
|
+
ApexTrigger: "Apex trigger"
|
|
30
|
+
};
|
|
31
|
+
function noun(typeName) {
|
|
32
|
+
const exact = TYPE_NOUN[typeName];
|
|
33
|
+
if (exact !== undefined)
|
|
34
|
+
return exact;
|
|
35
|
+
return typeName.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase();
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A component as a consultant would say it out loud.
|
|
39
|
+
*
|
|
40
|
+
* Api names STAY. `operator-voice.md`'s test is "the line is the audience, not the topic", and
|
|
41
|
+
* `Agreement.Form__c` is a Salesforce fact a consultant reads fluently — unlike `DIFF_VERIFIED`,
|
|
42
|
+
* which is a harness fact and never appears on this page.
|
|
43
|
+
*/
|
|
44
|
+
export function componentLabel(typeName, fullName) {
|
|
45
|
+
const word = noun(typeName);
|
|
46
|
+
const rule = CI_RULES[typeName];
|
|
47
|
+
if (rule?.shape === "layout") {
|
|
48
|
+
// "Agreement-Agreement Layout" — the object, then the label, separated by the FIRST hyphen.
|
|
49
|
+
const cut = fullName.indexOf("-");
|
|
50
|
+
if (cut > 0) {
|
|
51
|
+
return `**${fullName.slice(cut + 1)}** ${word} on ${fullName.slice(0, cut)}`;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (rule?.shape === "objectChild" || rule?.shape === "objectChildOptional") {
|
|
55
|
+
// A GLOBAL quick action has no dot and genuinely has no object — say so by omission rather
|
|
56
|
+
// than by inventing one.
|
|
57
|
+
const cut = fullName.indexOf(".");
|
|
58
|
+
if (cut > 0) {
|
|
59
|
+
return `**${fullName.slice(cut + 1)}** ${word} on ${fullName.slice(0, cut)}`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return `**${fullName}** ${word}`;
|
|
63
|
+
}
|
|
64
|
+
/** Fixed width, so two runs sit side by side in a transcript and compare honestly. */
|
|
65
|
+
const BAR_CELLS = 22;
|
|
66
|
+
/**
|
|
67
|
+
* The rate as a bar.
|
|
68
|
+
*
|
|
69
|
+
* Two guards, both about what the EYE reads before the number: a non-zero rate always shows at
|
|
70
|
+
* least one filled cell, and anything short of 100% always shows at least one empty one.
|
|
71
|
+
*/
|
|
72
|
+
export function rateBar(rate) {
|
|
73
|
+
const clamped = Math.max(0, Math.min(100, rate));
|
|
74
|
+
let filled = Math.round((clamped / 100) * BAR_CELLS);
|
|
75
|
+
if (clamped > 0 && filled === 0)
|
|
76
|
+
filled = 1;
|
|
77
|
+
if (clamped < 100 && filled === BAR_CELLS)
|
|
78
|
+
filled = BAR_CELLS - 1;
|
|
79
|
+
return `${"█".repeat(filled)}${"░".repeat(BAR_CELLS - filled)}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The planned scope, grouped by the requirement that owns each configuration.
|
|
83
|
+
*
|
|
84
|
+
* THE INVARIANT THIS EXISTS TO HOLD: summed across groups, `denominator` equals `scope.planned`
|
|
85
|
+
* and `deliveredCount` equals `scope.automated`. That is only true if this walks the three lists
|
|
86
|
+
* in the SAME precedence `computeScope` does — components, then manual, then unautomatable, each
|
|
87
|
+
* key taken once, first owner winning. A page whose green lines do not total its own headline
|
|
88
|
+
* teaches the reader to distrust both numbers.
|
|
89
|
+
*/
|
|
90
|
+
export function groupByRequirement(plannedScope, deliveredKeys, requirements) {
|
|
91
|
+
const delivered = new Set(deliveredKeys);
|
|
92
|
+
const titles = new Map(requirements.map((r) => [r.extId, r.title]));
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
const groups = new Map();
|
|
95
|
+
const take = (component, kind) => {
|
|
96
|
+
const key = plannedKey(component);
|
|
97
|
+
// FIRST OWNER WINS, across all three lists — the same collapse `computeScope` applies, and the
|
|
98
|
+
// reason a component named by two requirements cannot double the denominator.
|
|
99
|
+
if (seen.has(key))
|
|
100
|
+
return;
|
|
101
|
+
seen.add(key);
|
|
102
|
+
let group = groups.get(component.extId);
|
|
103
|
+
if (!group) {
|
|
104
|
+
group = {
|
|
105
|
+
extId: component.extId,
|
|
106
|
+
// An ext id with no index entry still gets a group. Dropping it would lose a real
|
|
107
|
+
// configuration from the page while leaving it in the panel's count.
|
|
108
|
+
//
|
|
109
|
+
// WHITESPACE COLLAPSED, and not cosmetically: this string is rendered inside a bold span
|
|
110
|
+
// in a block the supervisor pastes VERBATIM. Campfire titles reach the index through
|
|
111
|
+
// `stripRichText`, which turns `</p>` into a newline, so a two-paragraph requirement name
|
|
112
|
+
// would break the bold across lines and inject a stray line into the pasted block.
|
|
113
|
+
title: (titles.get(component.extId) ?? component.extId).replace(/\s+/g, " ").trim(),
|
|
114
|
+
components: [],
|
|
115
|
+
deliveredCount: 0,
|
|
116
|
+
denominator: 0,
|
|
117
|
+
fullyDelivered: false
|
|
118
|
+
};
|
|
119
|
+
groups.set(component.extId, group);
|
|
120
|
+
}
|
|
121
|
+
// A DELIVERY OUTRANKS A PLAN-TIME ROUTE — the same promotion `computeScope` applies before its
|
|
122
|
+
// own dedup. A configuration the plan routed to a person, that a wave then delivered, is
|
|
123
|
+
// deployed work: the panel counts it in both halves and removes it from the manual count.
|
|
124
|
+
// Grouping it any other way is precisely how the page's lines stop totalling the headline
|
|
125
|
+
// above them, which is the one thing this function exists to prevent.
|
|
126
|
+
const effective = kind !== "deployable" && delivered.has(key) ? "deployable" : kind;
|
|
127
|
+
const isDelivered = effective === "deployable" && delivered.has(key);
|
|
128
|
+
group.components.push({
|
|
129
|
+
key,
|
|
130
|
+
label: componentLabel(component.typeName, component.fullName),
|
|
131
|
+
delivered: isDelivered,
|
|
132
|
+
route: effective
|
|
133
|
+
});
|
|
134
|
+
if (effective !== "unautomatable")
|
|
135
|
+
group.denominator += 1;
|
|
136
|
+
if (isDelivered)
|
|
137
|
+
group.deliveredCount += 1;
|
|
138
|
+
};
|
|
139
|
+
for (const c of plannedScope.components)
|
|
140
|
+
take(c, "deployable");
|
|
141
|
+
for (const c of plannedScope.manual)
|
|
142
|
+
take(c, "manual");
|
|
143
|
+
for (const c of plannedScope.unautomatable ?? [])
|
|
144
|
+
take(c, "unautomatable");
|
|
145
|
+
for (const group of groups.values()) {
|
|
146
|
+
group.fullyDelivered = group.denominator > 0 && group.deliveredCount === group.denominator;
|
|
147
|
+
}
|
|
148
|
+
// Requirement-index order, so the page lists them the way the operator approved them. A group
|
|
149
|
+
// whose requirement is not in the index keeps insertion order at the end.
|
|
150
|
+
const rank = new Map(requirements.map((r, i) => [r.extId, i]));
|
|
151
|
+
return [...groups.values()].sort((a, b) => (rank.get(a.extId) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.extId) ?? Number.MAX_SAFE_INTEGER));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The page, as the operator reads it.
|
|
155
|
+
*
|
|
156
|
+
* The block this returns is pasted VERBATIM. Everything below its final heading — the manual
|
|
157
|
+
* steps, rollback, notes, the technical section — remains the supervisor's to write, because
|
|
158
|
+
* those are the parts that genuinely differ run to run.
|
|
159
|
+
*/
|
|
160
|
+
export function renderOperatorPage(report, requirements) {
|
|
161
|
+
const { scope } = report;
|
|
162
|
+
const lines = [];
|
|
163
|
+
// NO DENOMINATOR IS A REAL ANSWER. Rendering a zero rate here would state a measurement this
|
|
164
|
+
// batch never took, in the flattering-to-nobody direction.
|
|
165
|
+
if (!scope) {
|
|
166
|
+
lines.push(`## Delivery finished`, ``, `**automation rate unavailable — this run recorded no planned scope.**`, ``);
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
const failed = report.result === "FAILED";
|
|
170
|
+
const needsAttention = report.result === "NEEDS_ATTENTION";
|
|
171
|
+
const alreadySatisfied = report.result === "ALREADY_SATISFIED";
|
|
172
|
+
const nothingDeployed = report.result === "NO_WAVES";
|
|
173
|
+
// THE ONLY RUN THE READ-ONLY SENTENCE IS TRUE OF: one that asked for nothing.
|
|
174
|
+
//
|
|
175
|
+
// It used to cover every NO_WAVES batch, and that re-introduced the QA-round-2 defect at one
|
|
176
|
+
// remove: a batch of 12 planned configurations whose approval card the operator declined got a
|
|
177
|
+
// three-line page that mentioned none of them. "The remainder is never softened or omitted" is
|
|
178
|
+
// the invariant, and omitting the whole page is the strongest form of omitting it.
|
|
179
|
+
if (nothingDeployed && scope.planned === 0) {
|
|
180
|
+
lines.push(`## No changes were made`, ``, `No step changed this org — this run only read.`, ``);
|
|
181
|
+
return lines.join("\n");
|
|
182
|
+
}
|
|
183
|
+
const plural = (n, one, many) => (n === 1 ? one : many);
|
|
184
|
+
if (alreadySatisfied) {
|
|
185
|
+
// A RESULT, not an absence of one: the consultant asked a question about their org and got a
|
|
186
|
+
// definitive answer. The rate is honestly 0% and saying so would read as failure.
|
|
187
|
+
lines.push(`## ✅ Already configured — all ${scope.planned} requested ${plural(scope.planned, "configuration was", "configurations were")} already in place`);
|
|
188
|
+
}
|
|
189
|
+
else if (nothingDeployed) {
|
|
190
|
+
// TRUE OF BOTH CAUSES, and it must not choose between them: this renderer cannot tell an
|
|
191
|
+
// operator who declined the approval card from a batch nothing ever reached a wave for, and
|
|
192
|
+
// guessing would put a reason on the page that no evidence on disk supports.
|
|
193
|
+
lines.push(`## Nothing was deployed — ${scope.automated} of ${scope.planned} ${plural(scope.planned, "configuration", "configurations")} automated`);
|
|
194
|
+
}
|
|
195
|
+
else if (failed) {
|
|
196
|
+
lines.push(`## ❌ Delivery failed`);
|
|
197
|
+
}
|
|
198
|
+
else if (needsAttention) {
|
|
199
|
+
lines.push(`## ⚠️ Delivery finished — ${report.problemWaves} ${plural(report.problemWaves, "step needs", "steps need")} review`);
|
|
200
|
+
}
|
|
201
|
+
else if (scope.automated === scope.planned) {
|
|
202
|
+
// "ALL" IS A CLAIM ABOUT WHAT LANDED, not about what is left over. Branching on `manual === 0`
|
|
203
|
+
// printed "all 4 configurations automated" directly above a 75% bar and a ⬜ "not deployed"
|
|
204
|
+
// line, on a batch of 4 planned / 3 automated / 0 manual — flattering, and forbidden.
|
|
205
|
+
lines.push(`## ✅ Delivery complete — all ${scope.planned} ${plural(scope.planned, "configuration", "configurations")} automated`);
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
lines.push(`## ✅ Delivery complete — ${scope.automated} of ${scope.planned} ${plural(scope.planned, "configuration", "configurations")} automated`);
|
|
209
|
+
}
|
|
210
|
+
lines.push(``);
|
|
211
|
+
// The bar is celebratory framing and belongs only on a run that earned it. The RATE itself is
|
|
212
|
+
// stated either way — a bad batch still gets an honest measurement, just not a trophy.
|
|
213
|
+
if (alreadySatisfied) {
|
|
214
|
+
lines.push(`**Already in place — nothing needed to be deployed.** Each configuration below was found present when this batch was planned.`);
|
|
215
|
+
}
|
|
216
|
+
else if (nothingDeployed) {
|
|
217
|
+
lines.push(`No deployment step ran in this batch, so nothing below has been applied to your org.`);
|
|
218
|
+
}
|
|
219
|
+
else if (failed || needsAttention) {
|
|
220
|
+
lines.push(`${scope.automated} of ${scope.planned} configurations automated — ${scope.automationRate}%.`);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
lines.push(`**${scope.automationRate}% automated** — \`${rateBar(scope.automationRate)}\`` +
|
|
224
|
+
(scope.manual > 0
|
|
225
|
+
? ` — ${scope.manual} configuration${scope.manual === 1 ? " still needs" : "s still need"} you`
|
|
226
|
+
: ""));
|
|
227
|
+
}
|
|
228
|
+
lines.push(``);
|
|
229
|
+
const groups = report.plannedScope
|
|
230
|
+
? groupByRequirement(report.plannedScope, report.deliveredKeys, requirements)
|
|
231
|
+
: [];
|
|
232
|
+
// A requirement whose planned work is ENTIRELY data setup has no fraction to be right or wrong
|
|
233
|
+
// about, so it is out of this denominator for the same reason a requirement owning no planned
|
|
234
|
+
// component is: counting it would state a rollup the batch never attempted, and drag a perfect
|
|
235
|
+
// run below 100% on the strength of work that was never in the rate either.
|
|
236
|
+
const rollup = groups.filter((g) => g.denominator > 0);
|
|
237
|
+
const fullyDelivered = rollup.filter((g) => g.fullyDelivered).length;
|
|
238
|
+
// A checkmark is a claim of success — this panel earns it only on the same runs the headline
|
|
239
|
+
// and bar do. Every other result still states every count below, just without the mark.
|
|
240
|
+
const panel = [];
|
|
241
|
+
if (!failed && !needsAttention && !nothingDeployed && !alreadySatisfied) {
|
|
242
|
+
panel.push(`> ✅ **${scope.automated}** completed and verified in Salesforce`);
|
|
243
|
+
if (rollup.length > 0) {
|
|
244
|
+
panel.push(`> ✅ **${fullyDelivered} of ${rollup.length}** ${plural(rollup.length, "requirement", "requirements")} fully delivered`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (scope.manual > 0) {
|
|
248
|
+
panel.push(`> ⬜ **${scope.manual}** manual ${plural(scope.manual, "configuration remains", "configurations remain")}`);
|
|
249
|
+
}
|
|
250
|
+
// Beside the rate, in NEITHER half of it — with the reason, because a bare exclusion reads as
|
|
251
|
+
// a number being hidden. And with WHO IT IS WAITING ON: these are permission-set assignments,
|
|
252
|
+
// group memberships and custom-setting rows, so "not counted against the rate" alone let a
|
|
253
|
+
// consultant read "Still to do — 2" while three things needed a human. They stay out of
|
|
254
|
+
// `scope.manual` — that number is computed once, at close, and is never re-derived here — so
|
|
255
|
+
// they are stated as their own count instead.
|
|
256
|
+
if (scope.unautomatable > 0) {
|
|
257
|
+
const one = scope.unautomatable === 1;
|
|
258
|
+
panel.push(`> ⚙️ **${scope.unautomatable}** item${one ? " is" : "s are"} data setup, not deployable configuration — ${one ? "it still needs" : "they still need"} a person, and ${one ? "it is" : "they are"} not counted against the rate`);
|
|
259
|
+
}
|
|
260
|
+
if (scope.outsideScope > 0) {
|
|
261
|
+
const one = scope.outsideScope === 1;
|
|
262
|
+
panel.push(`> ➕ **${scope.outsideScope}** configuration${one ? "" : "s"} delivered that no approved plan asked for — counted in neither half of the rate`);
|
|
263
|
+
}
|
|
264
|
+
// Only when there IS a panel: an unconditional blank after an empty one is how a FAILED batch
|
|
265
|
+
// with no remainder emitted two blank lines before its first heading.
|
|
266
|
+
if (panel.length > 0)
|
|
267
|
+
lines.push(...panel, ``);
|
|
268
|
+
const mode = alreadySatisfied ? "alreadySatisfied" : nothingDeployed ? "nothingDeployed" : "delivered";
|
|
269
|
+
return `${lines.join("\n")}\n${renderGroups(report, requirements, mode)}`;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Above this many component lines, requirements with nothing outstanding collapse to their header.
|
|
273
|
+
*
|
|
274
|
+
* The pile IS the message — a reader should see the volume of what landed — but a 140-line scroll
|
|
275
|
+
* to reach the outstanding work trades one bad proportion for another. Requirements with anything
|
|
276
|
+
* left stay enumerated at any size, because those are the ones a reader must act on.
|
|
277
|
+
*/
|
|
278
|
+
const ENUMERATION_LIMIT = 60;
|
|
279
|
+
function renderGroups(report, requirements, mode) {
|
|
280
|
+
if (!report.plannedScope)
|
|
281
|
+
return "";
|
|
282
|
+
// ALREADY_SATISFIED confirms presence at PLANNING time, not through a wave — no wave ran, so
|
|
283
|
+
// `report.deliveredKeys` is always empty here (see batchReport.ts: it is narrowed from NO_WAVES).
|
|
284
|
+
// Grouping against it as-is would mark every configuration "⬜ needs a person" below, which is
|
|
285
|
+
// backwards for a batch whose entire premise is that nothing was missing. The confirmed set here
|
|
286
|
+
// is everything the plan named as deployable or manual — unautomatable items are handled by their
|
|
287
|
+
// own branch below and deliberately excluded.
|
|
288
|
+
const deliveredKeys = mode === "alreadySatisfied"
|
|
289
|
+
? [...report.plannedScope.components, ...report.plannedScope.manual].map(plannedKey)
|
|
290
|
+
: report.deliveredKeys;
|
|
291
|
+
const groups = groupByRequirement(report.plannedScope, deliveredKeys, requirements);
|
|
292
|
+
if (groups.length === 0)
|
|
293
|
+
return "";
|
|
294
|
+
const totalLines = groups.reduce((n, g) => n + g.components.length, 0);
|
|
295
|
+
const collapseClean = totalLines >= ENUMERATION_LIMIT;
|
|
296
|
+
const landed = report.scope?.automated ?? 0;
|
|
297
|
+
const planned = report.scope?.planned ?? 0;
|
|
298
|
+
const lines = [
|
|
299
|
+
mode === "alreadySatisfied"
|
|
300
|
+
? `### What was already in place`
|
|
301
|
+
: mode === "nothingDeployed"
|
|
302
|
+
? // "What landed — 0 configurations" would be true and would read as an epitaph. The pile
|
|
303
|
+
// on this page is the plan, and saying so is what makes the ⬜ lines below legible.
|
|
304
|
+
`### What this batch planned — ${planned} configuration${planned === 1 ? "" : "s"}`
|
|
305
|
+
: `### What landed in Salesforce — ${landed} configuration${landed === 1 ? "" : "s"}`,
|
|
306
|
+
``
|
|
307
|
+
];
|
|
308
|
+
for (const group of groups) {
|
|
309
|
+
// A ZERO DENOMINATOR IS NOT A SCORE. A requirement whose planned work is entirely data setup
|
|
310
|
+
// rendered "⬜ 0 of 0", which reads as a requirement that failed. It has no fraction, so it
|
|
311
|
+
// gets none — the ⚙️ lines beneath it say what it is waiting on.
|
|
312
|
+
if (group.denominator === 0) {
|
|
313
|
+
lines.push(`**${group.extId} — ${group.title}**`);
|
|
314
|
+
}
|
|
315
|
+
else {
|
|
316
|
+
const mark = group.fullyDelivered ? "✅" : "⬜";
|
|
317
|
+
lines.push(`**${group.extId} — ${group.title}** — ${mark} ${group.deliveredCount} of ${group.denominator}`);
|
|
318
|
+
}
|
|
319
|
+
if (collapseClean && group.fullyDelivered) {
|
|
320
|
+
lines.push(``);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
for (const c of group.components) {
|
|
324
|
+
if (c.route === "unautomatable") {
|
|
325
|
+
// BOTH HALVES OF THE TRUTH. Out of the rate, and still on somebody's desk — a
|
|
326
|
+
// permission-set assignment or a custom-setting row is work a person does by hand.
|
|
327
|
+
lines.push(`- ⚙️ ${c.label} — data setup, not a deployable configuration; needs a person`);
|
|
328
|
+
}
|
|
329
|
+
else if (c.delivered) {
|
|
330
|
+
lines.push(mode === "alreadySatisfied" ? `- ✅ ${c.label} — already present` : `- ✅ ${c.label}`);
|
|
331
|
+
}
|
|
332
|
+
else if (c.route === "manual") {
|
|
333
|
+
lines.push(`- ⬜ ${c.label} — needs a person`);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
lines.push(`- ⬜ ${c.label} — not deployed`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
lines.push(``);
|
|
340
|
+
}
|
|
341
|
+
// THE HEADING ONLY. Its items, their Setup paths and their time estimates stay the supervisor's
|
|
342
|
+
// to write — Campfire writeback is being reworked, and this page must not depend on it. What
|
|
343
|
+
// the code owns is the COUNT, so the proportion of the page cannot be reframed.
|
|
344
|
+
const manual = report.scope?.manual ?? 0;
|
|
345
|
+
if (manual > 0) {
|
|
346
|
+
lines.push(`### Still to do — ${manual} configuration${manual === 1 ? "" : "s"}`);
|
|
347
|
+
}
|
|
348
|
+
return lines.join("\n");
|
|
349
|
+
}
|