@a11ign/screenreader-fleet 0.1.0 → 0.3.0

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.
Files changed (129) hide show
  1. package/dist/capture-client.d.mts +0 -1
  2. package/dist/capture-client.mjs +144 -306
  3. package/dist/check-worker-code.d.mts +0 -1
  4. package/dist/check-worker-code.mjs +42 -141
  5. package/dist/cli-flags.d.mts +0 -1
  6. package/dist/cli-flags.mjs +33 -179
  7. package/dist/code-drift.d.mts +0 -1
  8. package/dist/command-line-census.d.mts +0 -1
  9. package/dist/compare-workers.d.mts +0 -1
  10. package/dist/compare-workers.mjs +383 -255
  11. package/dist/control-plane-isolation.d.mts +0 -1
  12. package/dist/deploy-worker.d.mts +0 -1
  13. package/dist/deploy-worker.mjs +101 -242
  14. package/dist/doctor.d.mts +0 -1
  15. package/dist/doctor.mjs +361 -809
  16. package/dist/fleet-consistency.d.mts +0 -1
  17. package/dist/fleet-consistency.mjs +155 -379
  18. package/dist/fleet-env.d.mts +0 -1
  19. package/dist/fleet-env.mjs +148 -438
  20. package/dist/fleet-scripts.d.mts +0 -1
  21. package/dist/git-safe-env.d.mts +0 -1
  22. package/dist/guest-run.d.mts +0 -1
  23. package/dist/host-address.d.mts +0 -1
  24. package/dist/host-address.mjs +19 -90
  25. package/dist/host-capacity.d.mts +0 -1
  26. package/dist/host-capacity.mjs +22 -136
  27. package/dist/host-metrics.d.mts +0 -1
  28. package/dist/index.d.ts +0 -1
  29. package/dist/index.mjs +231 -0
  30. package/dist/local-vm.d.ts +0 -1
  31. package/dist/measure-guard.d.mts +0 -1
  32. package/dist/normalise-fleet.d.mts +0 -1
  33. package/dist/npm-cli-executable.d.mts +0 -1
  34. package/dist/probe-outcome.d.mts +0 -1
  35. package/dist/probe-outcome.mjs +50 -96
  36. package/dist/protocol-guard.d.mts +0 -1
  37. package/dist/source-walk.d.mts +0 -1
  38. package/dist/src_fleet-scripts_mjs.mjs +16 -0
  39. package/dist/src_git-safe-env_mjs.mjs +9 -0
  40. package/dist/src_utm-deprecated_mjs.mjs +4 -0
  41. package/dist/transient-fault.d.mts +0 -1
  42. package/dist/transient-fault.mjs +21 -81
  43. package/dist/utm-deprecated.d.mts +0 -1
  44. package/dist/worker-code-check.d.mts +0 -1
  45. package/dist/worker-code-check.mjs +127 -76
  46. package/dist/worker-health.d.mts +0 -1
  47. package/dist/worker-health.mjs +16 -61
  48. package/dist/worker-http.d.mts +0 -1
  49. package/dist/worker-http.mjs +42 -234
  50. package/dist/worker-stats.d.mts +0 -1
  51. package/package.json +12 -5
  52. package/dist/capture-client.d.mts.map +0 -1
  53. package/dist/capture-client.mjs.map +0 -1
  54. package/dist/check-worker-code.d.mts.map +0 -1
  55. package/dist/check-worker-code.mjs.map +0 -1
  56. package/dist/cli-flags.d.mts.map +0 -1
  57. package/dist/cli-flags.mjs.map +0 -1
  58. package/dist/code-drift.d.mts.map +0 -1
  59. package/dist/code-drift.mjs +0 -284
  60. package/dist/code-drift.mjs.map +0 -1
  61. package/dist/command-line-census.d.mts.map +0 -1
  62. package/dist/command-line-census.mjs +0 -96
  63. package/dist/command-line-census.mjs.map +0 -1
  64. package/dist/compare-workers.d.mts.map +0 -1
  65. package/dist/compare-workers.mjs.map +0 -1
  66. package/dist/control-plane-isolation.d.mts.map +0 -1
  67. package/dist/control-plane-isolation.mjs +0 -67
  68. package/dist/control-plane-isolation.mjs.map +0 -1
  69. package/dist/deploy-worker.d.mts.map +0 -1
  70. package/dist/deploy-worker.mjs.map +0 -1
  71. package/dist/doctor.d.mts.map +0 -1
  72. package/dist/doctor.mjs.map +0 -1
  73. package/dist/fleet-consistency.d.mts.map +0 -1
  74. package/dist/fleet-consistency.mjs.map +0 -1
  75. package/dist/fleet-env.d.mts.map +0 -1
  76. package/dist/fleet-env.mjs.map +0 -1
  77. package/dist/fleet-scripts.d.mts.map +0 -1
  78. package/dist/fleet-scripts.mjs +0 -41
  79. package/dist/fleet-scripts.mjs.map +0 -1
  80. package/dist/git-safe-env.d.mts.map +0 -1
  81. package/dist/git-safe-env.mjs +0 -44
  82. package/dist/git-safe-env.mjs.map +0 -1
  83. package/dist/guest-run.d.mts.map +0 -1
  84. package/dist/guest-run.mjs +0 -164
  85. package/dist/guest-run.mjs.map +0 -1
  86. package/dist/host-address.d.mts.map +0 -1
  87. package/dist/host-address.mjs.map +0 -1
  88. package/dist/host-capacity.d.mts.map +0 -1
  89. package/dist/host-capacity.mjs.map +0 -1
  90. package/dist/host-metrics.d.mts.map +0 -1
  91. package/dist/host-metrics.mjs +0 -201
  92. package/dist/host-metrics.mjs.map +0 -1
  93. package/dist/index.d.ts.map +0 -1
  94. package/dist/index.js +0 -25
  95. package/dist/index.js.map +0 -1
  96. package/dist/local-vm.d.ts.map +0 -1
  97. package/dist/local-vm.js +0 -360
  98. package/dist/local-vm.js.map +0 -1
  99. package/dist/measure-guard.d.mts.map +0 -1
  100. package/dist/measure-guard.mjs +0 -73
  101. package/dist/measure-guard.mjs.map +0 -1
  102. package/dist/normalise-fleet.d.mts.map +0 -1
  103. package/dist/normalise-fleet.mjs +0 -76
  104. package/dist/normalise-fleet.mjs.map +0 -1
  105. package/dist/npm-cli-executable.d.mts.map +0 -1
  106. package/dist/npm-cli-executable.mjs +0 -159
  107. package/dist/npm-cli-executable.mjs.map +0 -1
  108. package/dist/probe-outcome.d.mts.map +0 -1
  109. package/dist/probe-outcome.mjs.map +0 -1
  110. package/dist/protocol-guard.d.mts.map +0 -1
  111. package/dist/protocol-guard.mjs +0 -121
  112. package/dist/protocol-guard.mjs.map +0 -1
  113. package/dist/source-walk.d.mts.map +0 -1
  114. package/dist/source-walk.mjs +0 -56
  115. package/dist/source-walk.mjs.map +0 -1
  116. package/dist/transient-fault.d.mts.map +0 -1
  117. package/dist/transient-fault.mjs.map +0 -1
  118. package/dist/utm-deprecated.d.mts.map +0 -1
  119. package/dist/utm-deprecated.mjs +0 -23
  120. package/dist/utm-deprecated.mjs.map +0 -1
  121. package/dist/worker-code-check.d.mts.map +0 -1
  122. package/dist/worker-code-check.mjs.map +0 -1
  123. package/dist/worker-health.d.mts.map +0 -1
  124. package/dist/worker-health.mjs.map +0 -1
  125. package/dist/worker-http.d.mts.map +0 -1
  126. package/dist/worker-http.mjs.map +0 -1
  127. package/dist/worker-stats.d.mts.map +0 -1
  128. package/dist/worker-stats.mjs +0 -143
  129. package/dist/worker-stats.mjs.map +0 -1
@@ -232,4 +232,3 @@ export type Mismatch = {
232
232
  why: string;
233
233
  values: Record<string, unknown>;
234
234
  };
235
- //# sourceMappingURL=fleet-consistency.d.mts.map
@@ -1,436 +1,212 @@
1
- // @ts-check
2
- /**
3
- * Are the guests actually interchangeable?
4
- *
5
- * The pool assumes they are. `captureAcrossPool` dispatches a case to whichever worker is free, the
6
- * cache lets any guest reuse another's evidence, and a good/bad pair is only comparable because both
7
- * halves came from equivalent machines. Every one of those assumptions is silently false the moment
8
- * two guests differ.
9
- *
10
- * They did differ, twice in one day, and neither was caught by tooling:
11
- *
12
- * - Edge auto-updated to 151 on one guest while the others stayed on 150, despite the updater being
13
- * policy-disabled. The cache key covers `browserVersion`, so evidence was not corrupted — but hit
14
- * rates halved and the corpus became heterogeneous.
15
- * - `StartupBoostEnabled` read 1 on two guests and 0 on a third. Nothing keys on that at all.
16
- *
17
- * The first was noticed by reading a boot log by eye; the second by a human looking at a screenshot of
18
- * a guest console. Neither is a detection mechanism. This is.
19
- *
20
- * Deliberately NOT fatal. A run on slightly mismatched guests is worse than one on matched guests and
21
- * far better than no run, and this project's rule is that a diagnostic must never be the thing that
22
- * takes the pool offline. It reports; the operator decides.
23
- */
24
- /**
25
- * Fields that must match across the fleet, and why each one matters.
26
- *
27
- * `workerCode` is absent on purpose: it changes when a comment changes, and the deploy tooling already
28
- * verifies it against the checkout. Flagging it here would cry wolf on every reworded line.
29
- */
30
- export const MUST_MATCH = [
31
- { path: "browserVersion", why: "Edge renders and announces differently across releases; it is in the cache key" },
32
- { path: "screenReaderVersion", why: "NVDA's wording changes between releases; it is in the cache key" },
33
- { path: "guidepupVersion", why: "guidepup parses NVDA's speech before we see it — 0.29.2 emitted an " +
34
- "intermittent U+FFFC where 0.31.0 emits a consistent empty segment, for the same page" },
35
- { path: "windowsVersion", why: "a second OS image would blend two corpora into one" },
36
- { path: "architecture", why: "ARM64 and x64 guests are not interchangeable evidence" },
37
- { path: "captureProtocol", why: "a guest on an older protocol produces evidence that means something else" },
38
- { path: "browserProfile", why: "a COLD browser profile is not the same evidence as a warm one: a fresh " +
39
- "`--user-data-dir` shows Edge's first-run surface, which NVDA's quick-nav escapes into and records " +
40
- "as phantom page content, and a learning profile is what drove the U+FFFC artefact from 3% to 31% " +
41
- "of affected captures. Two guests on different profiles are not interchangeable, and it is in the " +
42
- "cache key for the same reason. `adopted` on both sides means both predate the stamp, which is a " +
43
- "match rather than an unknown" },
44
- { path: "screenReaderSettings", why: "a guest capturing with `reportLanguage` off is blind to 3.1.2 and " +
45
- "one with it on is not — the same page yields different transcripts, so the two are not " +
46
- "interchangeable evidence. It is in the cache key for the same reason" },
47
- // A CACHE KEY that was not a consistency field, which is the worst combination.
48
- //
49
- // `provisionRevision` records what the guest actually has -- NVDA's config, Edge's policies,
50
- // ForegroundLockTimeout -- all of which change the evidence. It is already in the cache key
51
- // (`capture-cache.mjs`), so a fleet where one guest has been re-provisioned and the others report
52
- // `"unstamped"` produces two evidence populations. Nothing warned: the cache merely stopped hitting,
53
- // which reads as ordinary churn rather than as a split fleet. Re-provision the pool together.
54
- { path: "provisionRevision", why: "a re-provisioned guest has different NVDA/Edge configuration, and " +
55
- "it is already a cache key -- a split fleet shows up only as unexplained cache misses" },
56
- // A UNIFORMITY CLAIM PRINTED OVER A PROPERTY NOTHING CHECKED, which is the worst failure this file
57
- // can have -- worse than missing a field, because the operator was told the opposite.
58
- //
59
- // `fleet:status` printed "fleet CONSISTENT across 10 of 10 -- these workers are interchangeable for
60
- // capture" at 2026-09-22T18:21Z over a fleet running 1024x768 on five guests and 640x480 on the other
61
- // five (#1953; measured read-only on the live fleet, #1567/#1955). The nine fields above were all
62
- // matched and all true; the sentence they produced was not.
63
- //
64
- // `provisionRevision` does not already cover it, and this is the point. That stamp is written by the
65
- // provisioning script itself, so it records WHICH PROVISIONING RAN rather than what it achieved: a
66
- // guest whose display-driver install failed still gets the new stamp. All ten guests can report an
67
- // identical revision while five of them sit at 640x480 on a fallback adapter. A stamp cannot fail
68
- // closed on a thing it does not read.
69
- { path: "displayMode", why: "a guest capturing on a 640x480 desktop and one on 1024x768 are not " +
70
- "interchangeable evidence: the window is what a real display holds (#1561), and a page's CSS can " +
71
- "hide content below a width -- metoffice hides its h1 under 1280px. NOT a cache key: adding one " +
72
- "invalidates every cached capture, which is a separate and far more expensive decision than making " +
73
- "this sentence honest" },
74
- // The PIN, which is not the same field as the DESKTOP above (#1561). `displayMode` is what the screen
75
- // holds; this is what the worker asks Edge for, and a guest still running the pre-pin code asks for
76
- // nothing at all and gets `--start-maximized`. Both guests report the same `captureProtocol`, so the
77
- // protocol cannot separate them and a rolling deploy would otherwise split the corpus in silence --
78
- // which is the shape `provisionRevision` two entries up is here for, one deploy later.
79
- //
80
- // UNLIKE `displayMode`, this one IS a cache key (`environmentKey`), and that is a deliberate asymmetry
81
- // rather than an oversight: pinning the window is what makes the width a property of the capture rather
82
- // than of the box it ran on, so it is the value a stored capture can honestly be keyed by.
83
- { path: "windowSize", why: "a capture taken in a pinned 1024x768 window and one taken maximized on " +
84
- "whatever the desktop gave are not interchangeable evidence -- a page's CSS can hide content below " +
85
- "a width, and metoffice hides its h1 under 1280px. It is in the cache key (#1561), so a split " +
86
- "fleet also writes two evidence populations" },
87
- // THE RUNTIME THE EVIDENCE WAS PRODUCED BY -- and it is here as the THIRD STEP of `ceo`'s ruling on
88
- // #2063 rather than as a field arriving fresh: report it, pin provisioning so the fleet converges, and
89
- // only then may it gate (#2170). The first two are done. The pin is `worker_node_version` in the worker
90
- // role's `defaults/main.yml`, and the fleet converged on v24.20.0 at 2026-09-23T18:02Z -- read off all
91
- // ten guests' own `/health`, 10 of 10 reporting one value, against the 5/5 split measured at 06:55Z
92
- // that morning (`orchestrator`, #2170).
93
- //
94
- // WHY THE PRECONDITION WAS A `/health` READING AND NOT `provisionRevision`, which is the part worth
95
- // keeping: at 17:57Z, immediately after a CLEAN provision, three of the five upgraded guests still
96
- // reported v24.19.0. `/health` reports the runtime of the RUNNING worker process, not the installed
97
- // one, and only the deploy's restart moved them. That makes the reading stricter than "the pin
98
- // landed" -- which is the property this field needs, because the value it compares is written into
99
- // every corpus record BY that running process.
100
- { path: "nodeVersion", why: "the guest's Node runtime is recorded into every corpus record " +
101
- "(`export-screenreader-dataset.mjs`), so two guests on different runtimes write two populations " +
102
- "into one corpus and a good/bad pair can straddle them -- the comparison that pair exists to make " +
103
- "then carries a runtime difference nobody asked for. NOT a cache key: adding one invalidates every " +
104
- "capture stamped before it, which is the same separate and far more expensive decision " +
105
- "`displayMode` two entries up records" },
1
+ const MUST_MATCH = [
2
+ {
3
+ path: "browserVersion",
4
+ why: "Edge renders and announces differently across releases; it is in the cache key"
5
+ },
6
+ {
7
+ path: "screenReaderVersion",
8
+ why: "NVDA's wording changes between releases; it is in the cache key"
9
+ },
10
+ {
11
+ path: "guidepupVersion",
12
+ why: "guidepup parses NVDA's speech before we see it — 0.29.2 emitted an intermittent U+FFFC where 0.31.0 emits a consistent empty segment, for the same page"
13
+ },
14
+ {
15
+ path: "windowsVersion",
16
+ why: "a second OS image would blend two corpora into one"
17
+ },
18
+ {
19
+ path: "architecture",
20
+ why: "ARM64 and x64 guests are not interchangeable evidence"
21
+ },
22
+ {
23
+ path: "captureProtocol",
24
+ why: "a guest on an older protocol produces evidence that means something else"
25
+ },
26
+ {
27
+ path: "browserProfile",
28
+ why: "a COLD browser profile is not the same evidence as a warm one: a fresh `--user-data-dir` shows Edge's first-run surface, which NVDA's quick-nav escapes into and records as phantom page content, and a learning profile is what drove the U+FFFC artefact from 3% to 31% of affected captures. Two guests on different profiles are not interchangeable, and it is in the cache key for the same reason. `adopted` on both sides means both predate the stamp, which is a match rather than an unknown"
29
+ },
30
+ {
31
+ path: "screenReaderSettings",
32
+ why: "a guest capturing with `reportLanguage` off is blind to 3.1.2 and one with it on is not — the same page yields different transcripts, so the two are not interchangeable evidence. It is in the cache key for the same reason"
33
+ },
34
+ {
35
+ path: "provisionRevision",
36
+ why: "a re-provisioned guest has different NVDA/Edge configuration, and it is already a cache key -- a split fleet shows up only as unexplained cache misses"
37
+ },
38
+ {
39
+ path: "displayMode",
40
+ why: "a guest capturing on a 640x480 desktop and one on 1024x768 are not interchangeable evidence: the window is what a real display holds (#1561), and a page's CSS can hide content below a width -- metoffice hides its h1 under 1280px. NOT a cache key: adding one invalidates every cached capture, which is a separate and far more expensive decision than making this sentence honest"
41
+ },
42
+ {
43
+ path: "windowSize",
44
+ why: "a capture taken in a pinned 1024x768 window and one taken maximized on whatever the desktop gave are not interchangeable evidence -- a page's CSS can hide content below a width, and metoffice hides its h1 under 1280px. It is in the cache key (#1561), so a split fleet also writes two evidence populations"
45
+ },
46
+ {
47
+ path: "nodeVersion",
48
+ why: "the guest's Node runtime is recorded into every corpus record (`export-screenreader-dataset.mjs`), so two guests on different runtimes write two populations into one corpus and a good/bad pair can straddle them -- the comparison that pair exists to make then carries a runtime difference nobody asked for. NOT a cache key: adding one invalidates every capture stamped before it, which is the same separate and far more expensive decision `displayMode` two entries up records"
49
+ }
50
+ ];
51
+ const REPORTED_ONLY = [
52
+ {
53
+ path: "displayAdapter",
54
+ why: "the adapter is what decides whether a pinned display mode can be held at all -- on 2026-09-22 workers 7-11 were on Microsoft's Basic Display Adapter and captured at 640x480 under a `provisionRevision` identical to their peers' (#1955); the pinned Intel driver install resolved that, and all ten read an Intel adapter at CM_PROB_NONE on 2026-09-23. NOT a gate, and no longer because nobody reports it: 10 of 10 guests report it since 2026-09-23T18:02Z, reading `Intel(R) UHD Graphics 630` on nine and `Intel(R) HD Graphics 630` on one as of 2026-09-23. That single letter is a real hardware difference rather than a driver fallback, so no provisioning run can converge it and a gate would refuse that guest for ever (#2063, reading on #2170)"
55
+ }
106
56
  ];
107
- /**
108
- * THE THIRD CHANNEL: fields that are COMPARED AND NAMED, and that gate nothing — #2063.
109
- *
110
- * `MUST_MATCH` above is a capture gate in both its channels. A disagreement there sets `consistent:
111
- * false` and `capture-fleet-guard` exits 3; a field any compared guest fails to report lands in
112
- * `fields.coverage` and, since #2047, exits 3 as well. So a field that belongs in the REPORT but must not
113
- * refuse a run has nowhere to go in either — and adding it to one of them anyway is not a small
114
- * mis-filing: `displayAdapter` reads one value on nine guests and another on the tenth, one letter apart,
115
- * so a gate on it would refuse every capture on this fleet for ever.
116
- *
117
- * THIS CHANNEL IS A WAITING ROOM FOR SOME FIELDS AND A PERMANENT HOME FOR OTHERS, and the two must not be
118
- * confused. `nodeVersion` was the first kind: `ceo` ruled the order on #2063, 2026-09-23 — **report it,
119
- * then pin provisioning so the fleet converges, and only then may it join `MUST_MATCH`** — and #2170 is
120
- * that last step, taken once the fleet read one runtime on all ten guests. `displayAdapter` is the
121
- * second kind: the difference it reports is HARDWARE (`Intel(R) UHD Graphics 630` against
122
- * `Intel(R) HD Graphics 630` on one box), so no provisioning run converges it and it can never graduate
123
- * the way `nodeVersion` did. A field arriving here needs its exit named, or "not yet a gate" decays into
124
- * "never looked at again".
125
- *
126
- * So these fields are compared exactly as the gating ones are, and their drift is named on the verdict
127
- * line with each guest's value — but they contribute to NEITHER `mismatches` NOR `fields.coverage`, which
128
- * are the only two things any gate reads. A fleet differing on one of them is `consistent: true`.
129
- *
130
- * A field NOBODY reports is `unreported` here rather than silent, which is the same distinction #1997
131
- * drew for the gating fields: a field compared on nobody draws no values to disagree about and would
132
- * otherwise read exactly like a field every guest agrees on. It is the state `displayAdapter` sat in
133
- * until the worker carrying it was deployed on 2026-09-23, and the state any field added here starts in,
134
- * and it must be readable as such without refusing anything.
135
- */
136
- export const REPORTED_ONLY = [
137
- // `ceo`'s fold-in on the same row: "nothing in this repo reports the display ADAPTER either ... whatever
138
- // shape this row lands for 'reported, visible, not yet a gate', the adapter belongs in it rather than in
139
- // a fourth row." It is the seam `displayMode` sat on -- on 2026-09-22, workers 2-6 on the Intel adapter,
140
- // 7-11 on Microsoft's Basic Display Adapter after a driver install failed rc 1014 -- and it is a DIFFERENT field
141
- // from the driver VERSION, which `ceo` ruled on #1567 does not split the fleet.
142
- { path: "displayAdapter", why: "the adapter is what decides whether a pinned display mode can be held " +
143
- "at all -- on 2026-09-22 workers 7-11 were on Microsoft's Basic Display Adapter and captured at " +
144
- "640x480 under a `provisionRevision` identical to their peers' (#1955); the pinned Intel driver " +
145
- "install resolved that, and all ten read an Intel adapter at CM_PROB_NONE on 2026-09-23. NOT a " +
146
- "gate, and no longer because nobody reports it: 10 of 10 guests report it since " +
147
- "2026-09-23T18:02Z, reading `Intel(R) UHD Graphics 630` on nine and `Intel(R) HD Graphics 630` " +
148
- "on one as of 2026-09-23. That single letter is a real hardware difference rather than a driver " +
149
- "fallback, so no provisioning run can converge it and a gate would refuse that guest for ever " +
150
- "(#2063, reading on #2170)" },
57
+ const POLICY_MUST_MATCH = [
58
+ "StartupBoostEnabled",
59
+ "BackgroundModeEnabled"
151
60
  ];
152
- /**
153
- * One field the guests disagree about, and the guests' values for it.
154
- *
155
- * Named once because it is produced by `fleetConsistency` and consumed by `describeMismatches`, and the
156
- * two had already drifted apart the moment either was annotated — `object` on one side against
157
- * `Record<string, unknown>` on the other, which typecheck caught in the test that calls them in sequence.
158
- * Two spellings of one shape is the duplication this repo names as its most expensive recurring defect,
159
- * and a typedef is the cheapest form of "delete a copy".
160
- *
161
- * @typedef {{field: string, why: string, values: Record<string, unknown>}} Mismatch
162
- */
163
- /** Edge policy values every guest must agree on, checked separately because they come from /diagnostics. */
164
- export const POLICY_MUST_MATCH = ["StartupBoostEnabled", "BackgroundModeEnabled"];
165
- /**
166
- * Which fields the verdict is actually ABOUT -- #1997.
167
- *
168
- * `compared` NAMES the fields at least one guest reported a value for; `unchecked` names the ones that
169
- * drew a value from NOBODY. The second list exists because `check()` below skips an absent value, which
170
- * is right (a rolling deploy must not flag the guest it has not reached yet) and has a cost: a field no
171
- * guest reports contributes no values, `new Set([]).size > 1` is false, and it reads as agreement.
172
- * A field compared on nobody and a field equal on everybody produced the IDENTICAL verdict.
173
- *
174
- * Measured 2026-09-22T20:09Z on the live fleet, at the merge of #1953: nine fields at 10/10 guests and
175
- * `displayMode` at 0/10, and `fleet:status` printed `fleet CONSISTENT across 10 of 10 -- these workers
176
- * are interchangeable for capture`. The display was still not compared. Naming the two lists is what
177
- * lets a reader tell "they agree" from "nobody was asked".
178
- *
179
- * `coverage` IS THE SAME QUESTION WITHOUT THE THRESHOLD -- #2019. The two lists above are a PARTITION on
180
- * "did anybody report it", which answers 0-of-10 and says nothing about 1-of-10; `fleet:status` read the
181
- * second as agreement about ten guests on the strength of one. So every asked field also carries how many
182
- * of the asked guests actually reported it, and the verdict draws its own line. `compared`/`unchecked`
183
- * stay because they are what a caller greps for the REMEDY -- a field at 0 sends a reader to the field,
184
- * a field at k sends them to the boxes -- and `coverage` is the measurement both are derived from.
185
- *
186
- * @typedef {{field: string, reported: number, asked: number}} FieldReporters
187
- * @typedef {{compared: string[], unchecked: string[], coverage: FieldReporters[]}} FieldCoverage
188
- */
189
- /**
190
- * One reported-only field that has something to say, and WHICH thing it is saying — #2063.
191
- *
192
- * `drifted` is the guests giving more than one value; `unreported` is some or all of them giving none.
193
- * Two states rather than a boolean because the REMEDY differs and the report has to name it: drift sends
194
- * a reader to the provisioning pin, silence sends them to the worker deploy. Neither is a refusal.
195
- *
196
- * It carries `reported`/`asked` for the same reason `FieldReporters` does — a count is what lets a reader
197
- * tell 0 of 10 from 9 of 10 — and the values map for the reason `Mismatch` does: drift detected and not
198
- * located is not actionable.
199
- *
200
- * @typedef {{field: string, why: string, values: Record<string, unknown>, reported: number,
201
- * asked: number, state: "drifted" | "unreported"}} ReportedDrift
202
- */
203
- /**
204
- * ONE FIELD READ ACROSS THE FLEET, before anybody decides what it means.
205
- *
206
- * Split out from `fleetConsistency` when the third channel arrived (#2063), because the READING and the
207
- * CONSEQUENCE are two things and only the second differs between the channels: `MUST_MATCH` turns a
208
- * reading into a mismatch and a coverage row, `REPORTED_ONLY` turns the same reading into a named drift
209
- * that gates nothing. One reader means the two channels cannot drift apart in how they compare — a
210
- * second copy of this loop is how a "reported-only" field would end up counted differently from a gating
211
- * one and nobody would know which was right.
212
- *
213
- * @typedef {{worker: string, environment?: Record<string, unknown>, policy?: Record<string, unknown>}} Guest
214
- * @typedef {{field: string, why: string, values: Record<string, unknown>, reported: number, asked: number}} Reading
215
- *
216
- * @param {Guest[]} present
217
- * @param {{field: string, why: string, source: (guest: Guest) => Record<string, unknown> | undefined,
218
- * key: string}} ask `source` is the BLOCK this field lives in, not the value: coverage has to tell
219
- * "the guest did not report this field" from "this caller never collected that block at all", and only
220
- * the block answers the second
221
- * @returns {Reading}
222
- */
223
61
  function readField(present, { field, why, source, key }) {
224
- /** @type {Record<string, unknown>} */
225
62
  const values = {};
226
- // THE REPORTER COUNT IS COUNTED, NEVER READ OFF `values` -- #2019, and #2018 is why. The map is keyed
227
- // by worker name, and a caller that supplies guests without one collapses every guest onto a single
228
- // `undefined` key -- which `capture-real-pages.mjs` does -- so `Object.keys(values).length` reads 1
229
- // for any number of reporting guests, understating coverage in exactly the cases a coverage number
230
- // exists for. `asked` counts the guests that carried the BLOCK; `reported` counts the ones that
231
- // carried a VALUE in it; both are incremented on the guest, so neither can be collapsed by a key.
232
63
  let asked = 0;
233
64
  let reported = 0;
234
- for (const guest of present) {
65
+ for (const guest of present){
235
66
  const block = source(guest);
236
- // A BLOCK THIS CALLER DID NOT COLLECT IS A FACT ABOUT THE PROBE, NOT THE FLEET. `/health` carries
237
- // no policy, so every production caller passes `policy: undefined`; calling those fields
238
- // "compared on nobody" would report a permanent gap on an axis nobody asked about, and drown the
239
- // one this list exists to surface.
240
- if (block === undefined || block === null)
241
- continue;
67
+ if (null == block) continue;
242
68
  asked += 1;
243
69
  const value = block[key];
244
- // Absent is not a mismatch: an older worker that does not report a field must not be flagged
245
- // against newer ones. Only DIFFERING known values are evidence of drift.
246
- if (value !== undefined && value !== null) {
70
+ if (null != value) {
247
71
  reported += 1;
248
72
  values[guest.worker] = value;
249
73
  }
250
74
  }
251
- return { field, why, values, reported, asked };
75
+ return {
76
+ field,
77
+ why,
78
+ values,
79
+ reported,
80
+ asked
81
+ };
252
82
  }
253
- /** How many distinct values the guests actually gave for one field. @param {Reading} reading */
254
83
  function distinctValues({ values }) {
255
84
  return new Set(Object.values(values)).size;
256
85
  }
257
- /**
258
- * THE GATING CONSEQUENCE of a set of readings: what disagrees, and what nobody was asked.
259
- *
260
- * @param {Reading[]} readings
261
- * @returns {{mismatches: Mismatch[], fields: FieldCoverage}}
262
- */
263
86
  function gateOn(readings) {
264
- /** @type {Mismatch[]} */
265
87
  const mismatches = [];
266
- /** @type {FieldCoverage} */
267
- const fields = { compared: [], unchecked: [], coverage: [] };
268
- for (const reading of readings) {
88
+ const fields = {
89
+ compared: [],
90
+ unchecked: [],
91
+ coverage: []
92
+ };
93
+ for (const reading of readings){
269
94
  const { field, why, values, reported, asked } = reading;
270
- // A FIELD NO GUEST REPORTED IS CANNOT ASK, NOT ALL AGREE. One value from one guest still counts as
271
- // compared -- that is the rolling-deploy case the skip above is for, and it is a different claim
272
- // from nobody having been asked at all. How MANY reported it is a third claim again, and it goes on
273
- // `coverage` rather than into this partition: the lists answer the remedy, the count answers the
274
- // verdict (#2019).
275
95
  if (asked > 0) {
276
96
  (reported > 0 ? fields.compared : fields.unchecked).push(field);
277
- fields.coverage.push({ field, reported, asked });
97
+ fields.coverage.push({
98
+ field,
99
+ reported,
100
+ asked
101
+ });
278
102
  }
279
- if (distinctValues(reading) > 1)
280
- mismatches.push({ field, why, values });
103
+ if (distinctValues(reading) > 1) mismatches.push({
104
+ field,
105
+ why,
106
+ values
107
+ });
281
108
  }
282
- return { mismatches, fields };
109
+ return {
110
+ mismatches,
111
+ fields
112
+ };
283
113
  }
284
- /**
285
- * THE REPORTED-ONLY CONSEQUENCE: the same readings, as something to SAY rather than something to refuse.
286
- *
287
- * A field the guests agree on yields NOTHING, and that is the half of this that a test can most easily
288
- * lose. `drifted` and `unreported` are both findings; agreement is not, and an implementation that
289
- * returned every reported-only field regardless would satisfy "the drift is named" while naming a fleet
290
- * that has none.
291
- *
292
- * `asked === 0` yields nothing either, for the reason `gateOn` skips it: a block this caller never
293
- * collected is a fact about the probe.
294
- *
295
- * @param {Reading[]} readings
296
- * @returns {ReportedDrift[]}
297
- */
298
114
  function driftOf(readings) {
299
- return readings.flatMap((reading) => {
115
+ return readings.flatMap((reading)=>{
300
116
  const state = driftState(reading);
301
- return state === null ? [] : [{ ...reading, state }];
117
+ return null === state ? [] : [
118
+ {
119
+ ...reading,
120
+ state
121
+ }
122
+ ];
302
123
  });
303
124
  }
304
- /**
305
- * @param {Reading} reading
306
- * @returns {"drifted" | "unreported" | null}
307
- */
308
125
  function driftState(reading) {
309
- if (distinctValues(reading) > 1)
310
- return "drifted";
311
- // NOT REPORTED BY EVERYBODY WHO WAS ASKED, which covers #1997's nobody and #2019's some in one line --
312
- // for a GATING field those are two refusals with different remedies, and here they are one sentence
313
- // with the counts in it, because nothing is being refused.
314
- if (reading.asked > 0 && reading.reported < reading.asked)
315
- return "unreported";
126
+ if (distinctValues(reading) > 1) return "drifted";
127
+ if (reading.asked > 0 && reading.reported < reading.asked) return "unreported";
316
128
  return null;
317
129
  }
318
- /**
319
- * Compare guests field by field.
320
- *
321
- * @param {Guest[]} guests
322
- * @returns {{consistent: boolean, mismatches: Mismatch[], compared: number, fields: FieldCoverage,
323
- * reportedOnly: ReportedDrift[]}}
324
- * `compared` is how many guests the verdict is actually ABOUT -- #920. A guest with no `environment`
325
- * and no `policy` is dropped below before comparing, so it is not the length of what was passed in,
326
- * and a caller that reports `consistent` without it is stating agreement over a set it cannot name.
327
- * `fields` is that same question one axis over: WHICH fields the verdict is about -- #1997.
328
- * `reportedOnly` is the third channel (#2063): compared, named, and part of NEITHER of the two above,
329
- * which is what makes it something to report rather than something to refuse.
330
- */
331
- export function fleetConsistency(guests) {
332
- const present = (guests ?? []).filter((g) => g && (g.environment || g.policy));
333
- // One guest is trivially consistent with itself, and zero is not a fleet. Neither is a finding, and
334
- // neither is a field nothing compared: with nobody to compare against, coverage is not a question yet.
335
- if (present.length < 2) {
336
- // A FRESH object rather than a shared constant: this is returned to a caller, and one shared literal
337
- // is one `push` away from a coverage list that grows across unrelated readings.
338
- return { consistent: true, mismatches: [], compared: present.length,
339
- fields: { compared: [], unchecked: [], coverage: [] }, reportedOnly: [] };
340
- }
341
- /** @param {Guest} guest */
342
- const environmentOf = (guest) => guest.environment;
130
+ function fleetConsistency(guests) {
131
+ const present = (guests ?? []).filter((g)=>g && (g.environment || g.policy));
132
+ if (present.length < 2) return {
133
+ consistent: true,
134
+ mismatches: [],
135
+ compared: present.length,
136
+ fields: {
137
+ compared: [],
138
+ unchecked: [],
139
+ coverage: []
140
+ },
141
+ reportedOnly: []
142
+ };
143
+ const environmentOf = (guest)=>guest.environment;
343
144
  const gated = [
344
- ...MUST_MATCH.map(({ path, why }) => readField(present, { field: path, why, source: environmentOf, key: path })),
345
- ...POLICY_MUST_MATCH.map((name) => readField(present, { field: `edgePolicy.${name}`,
346
- why: "guests with different browser behaviour are not interchangeable",
347
- source: (guest) => guest.policy, key: name })),
145
+ ...MUST_MATCH.map(({ path, why })=>readField(present, {
146
+ field: path,
147
+ why,
148
+ source: environmentOf,
149
+ key: path
150
+ })),
151
+ ...POLICY_MUST_MATCH.map((name)=>readField(present, {
152
+ field: `edgePolicy.${name}`,
153
+ why: "guests with different browser behaviour are not interchangeable",
154
+ source: (guest)=>guest.policy,
155
+ key: name
156
+ }))
348
157
  ];
349
158
  const { mismatches, fields } = gateOn(gated);
350
- const reportedOnly = driftOf(REPORTED_ONLY.map(({ path, why }) => readField(present, { field: path, why, source: environmentOf, key: path })));
351
- return { consistent: mismatches.length === 0, mismatches, compared: present.length, fields,
352
- reportedOnly };
159
+ const reportedOnly = driftOf(REPORTED_ONLY.map(({ path, why })=>readField(present, {
160
+ field: path,
161
+ why,
162
+ source: environmentOf,
163
+ key: path
164
+ })));
165
+ return {
166
+ consistent: 0 === mismatches.length,
167
+ mismatches,
168
+ compared: present.length,
169
+ fields,
170
+ reportedOnly
171
+ };
353
172
  }
354
- /**
355
- * One line per reported-only field that has something to say, in `describeMismatches`'s own shape.
356
- *
357
- * SEPARATE FROM `describeMismatches` rather than a flag on it, because the two lines make different
358
- * claims and a reader has to be able to tell them apart at a glance: that one says the fleet is not
359
- * usable for a capture run, this one says the fleet is not identical and the run may proceed anyway.
360
- * Folding them would put the ruling's own distinction behind a boolean argument.
361
- *
362
- * @param {ReportedDrift[]} drifts
363
- * @returns {string[]}
364
- */
365
- export function describeReportedOnly(drifts) {
366
- return (drifts ?? []).map((drift) => `${drift.field}: ${reportedDetail(drift)} — ${drift.why}`);
173
+ function describeReportedOnly(drifts) {
174
+ return (drifts ?? []).map((drift)=>`${drift.field}: ${reportedDetail(drift)} — ${drift.why}`);
367
175
  }
368
- /**
369
- * NAMED WITH EACH GUEST'S VALUE where there is one, and with the count where there is not.
370
- *
371
- * `not reported by any of 10 guests` is a different finding from `.2=v24.19.0 .7=v24.20.0` and from
372
- * `.2=v24.19.0 (1 of 10 guests reported it)`, and a reader acts differently on each: the first sends
373
- * them to the worker code, the second to the boxes, the third to the deploy that has not finished.
374
- *
375
- * @param {ReportedDrift} drift
376
- */
377
176
  function reportedDetail({ values, reported, asked, state }) {
378
177
  const label = labelWorkers(Object.keys(values));
379
- const detail = Object.entries(values).map(([worker, value]) => `${label.get(worker)}=${value}`).join(" ");
380
- if (state === "drifted")
381
- return detail;
382
- if (reported === 0)
383
- return `not reported by any of ${asked} guests`;
178
+ const detail = Object.entries(values).map(([worker, value])=>`${label.get(worker)}=${value}`).join(" ");
179
+ if ("drifted" === state) return detail;
180
+ if (0 === reported) return `not reported by any of ${asked} guests`;
384
181
  return `${detail} (${reported} of ${asked} guests reported it)`;
385
182
  }
386
- /**
387
- * One line per mismatch, naming the guests, so the report is actionable rather than just alarming.
388
- *
389
- * @param {Mismatch[]} mismatches
390
- * @returns {string[]}
391
- */
392
- export function describeMismatches(mismatches) {
393
- return (mismatches ?? []).map(({ field, values, why }) => {
183
+ function describeMismatches(mismatches) {
184
+ return (mismatches ?? []).map(({ field, values, why })=>{
394
185
  const label = labelWorkers(Object.keys(values));
395
- const detail = Object.entries(values).map(([worker, value]) => `${label.get(worker)}=${value}`).join(" ");
186
+ const detail = Object.entries(values).map(([worker, value])=>`${label.get(worker)}=${value}`).join(" ");
396
187
  return `${field}: ${detail} — ${why}`;
397
188
  });
398
189
  }
399
- /**
400
- * A short, UNAMBIGUOUS name per worker.
401
- *
402
- * `.4` is the right label for a guest's full `http://<address>:8765` in a table, and it is the wrong one the moment
403
- * two workers share a last octet — two boxes on one host with different ports, or two subnets that
404
- * happen to meet. The report then says which FIELD drifted and gives two identically-named values, which
405
- * is drift detected and not located: `browserVersion: .1=151.0.1 .1=150.0.9`.
406
- *
407
- * So the short form is used only while it distinguishes, and the full host:port otherwise. Shortening is
408
- * a readability optimisation, and it must never cost the thing the line exists to convey.
409
- */
410
- /**
411
- * @param {string[]} workers
412
- * @returns {Map<string, string>}
413
- */
414
190
  function labelWorkers(workers) {
415
- const short = new Map(workers.map((w) => [w, shortWorker(w)]));
191
+ const short = new Map(workers.map((w)=>[
192
+ w,
193
+ shortWorker(w)
194
+ ]));
416
195
  const counts = new Map();
417
- for (const name of short.values())
418
- counts.set(name, (counts.get(name) ?? 0) + 1);
419
- return new Map(workers.map((w) => {
420
- // `short.get(w)` cannot miss — every worker was put in the map on the line above — but TypeScript
421
- // cannot know that, and `?? w` is the honest fallback rather than a non-null assertion: an unlabelled
422
- // worker printed as its own URL is still a located mismatch, which is what this function is for.
196
+ for (const name of short.values())counts.set(name, (counts.get(name) ?? 0) + 1);
197
+ return new Map(workers.map((w)=>{
423
198
  const name = short.get(w) ?? w;
424
- return [w, (counts.get(name) ?? 0) > 1 ? hostAndPort(w) : name];
199
+ return [
200
+ w,
201
+ (counts.get(name) ?? 0) > 1 ? hostAndPort(w) : name
202
+ ];
425
203
  }));
426
204
  }
427
- /** a guest's full `http://<address>:8765` is noise in a table; `.4` is not. @param {string} worker */
428
205
  function shortWorker(worker) {
429
206
  const host = /\/\/([^:/]+)/.exec(worker)?.[1] ?? worker;
430
207
  return host.includes(".") ? `.${host.split(".").pop()}` : host;
431
208
  }
432
- /** @param {string} worker */
433
209
  function hostAndPort(worker) {
434
210
  return /\/\/(.+?)\/?$/.exec(worker)?.[1] ?? worker;
435
211
  }
436
- //# sourceMappingURL=fleet-consistency.mjs.map
212
+ export { MUST_MATCH, POLICY_MUST_MATCH, REPORTED_ONLY, describeMismatches, describeReportedOnly, fleetConsistency };
@@ -225,4 +225,3 @@ export type Host = {
225
225
  host: string;
226
226
  capture: boolean;
227
227
  };
228
- //# sourceMappingURL=fleet-env.d.mts.map