@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.
- package/dist/capture-client.d.mts +0 -1
- package/dist/capture-client.mjs +144 -306
- package/dist/check-worker-code.d.mts +0 -1
- package/dist/check-worker-code.mjs +42 -141
- package/dist/cli-flags.d.mts +0 -1
- package/dist/cli-flags.mjs +33 -179
- package/dist/code-drift.d.mts +0 -1
- package/dist/command-line-census.d.mts +0 -1
- package/dist/compare-workers.d.mts +0 -1
- package/dist/compare-workers.mjs +383 -255
- package/dist/control-plane-isolation.d.mts +0 -1
- package/dist/deploy-worker.d.mts +0 -1
- package/dist/deploy-worker.mjs +101 -242
- package/dist/doctor.d.mts +0 -1
- package/dist/doctor.mjs +361 -809
- package/dist/fleet-consistency.d.mts +0 -1
- package/dist/fleet-consistency.mjs +155 -379
- package/dist/fleet-env.d.mts +0 -1
- package/dist/fleet-env.mjs +148 -438
- package/dist/fleet-scripts.d.mts +0 -1
- package/dist/git-safe-env.d.mts +0 -1
- package/dist/guest-run.d.mts +0 -1
- package/dist/host-address.d.mts +0 -1
- package/dist/host-address.mjs +19 -90
- package/dist/host-capacity.d.mts +0 -1
- package/dist/host-capacity.mjs +22 -136
- package/dist/host-metrics.d.mts +0 -1
- package/dist/index.d.ts +0 -1
- package/dist/index.mjs +231 -0
- package/dist/local-vm.d.ts +0 -1
- package/dist/measure-guard.d.mts +0 -1
- package/dist/normalise-fleet.d.mts +0 -1
- package/dist/npm-cli-executable.d.mts +0 -1
- package/dist/probe-outcome.d.mts +0 -1
- package/dist/probe-outcome.mjs +50 -96
- package/dist/protocol-guard.d.mts +0 -1
- package/dist/source-walk.d.mts +0 -1
- package/dist/src_fleet-scripts_mjs.mjs +16 -0
- package/dist/src_git-safe-env_mjs.mjs +9 -0
- package/dist/src_utm-deprecated_mjs.mjs +4 -0
- package/dist/transient-fault.d.mts +0 -1
- package/dist/transient-fault.mjs +21 -81
- package/dist/utm-deprecated.d.mts +0 -1
- package/dist/worker-code-check.d.mts +0 -1
- package/dist/worker-code-check.mjs +127 -76
- package/dist/worker-health.d.mts +0 -1
- package/dist/worker-health.mjs +16 -61
- package/dist/worker-http.d.mts +0 -1
- package/dist/worker-http.mjs +42 -234
- package/dist/worker-stats.d.mts +0 -1
- package/package.json +12 -5
- package/dist/capture-client.d.mts.map +0 -1
- package/dist/capture-client.mjs.map +0 -1
- package/dist/check-worker-code.d.mts.map +0 -1
- package/dist/check-worker-code.mjs.map +0 -1
- package/dist/cli-flags.d.mts.map +0 -1
- package/dist/cli-flags.mjs.map +0 -1
- package/dist/code-drift.d.mts.map +0 -1
- package/dist/code-drift.mjs +0 -284
- package/dist/code-drift.mjs.map +0 -1
- package/dist/command-line-census.d.mts.map +0 -1
- package/dist/command-line-census.mjs +0 -96
- package/dist/command-line-census.mjs.map +0 -1
- package/dist/compare-workers.d.mts.map +0 -1
- package/dist/compare-workers.mjs.map +0 -1
- package/dist/control-plane-isolation.d.mts.map +0 -1
- package/dist/control-plane-isolation.mjs +0 -67
- package/dist/control-plane-isolation.mjs.map +0 -1
- package/dist/deploy-worker.d.mts.map +0 -1
- package/dist/deploy-worker.mjs.map +0 -1
- package/dist/doctor.d.mts.map +0 -1
- package/dist/doctor.mjs.map +0 -1
- package/dist/fleet-consistency.d.mts.map +0 -1
- package/dist/fleet-consistency.mjs.map +0 -1
- package/dist/fleet-env.d.mts.map +0 -1
- package/dist/fleet-env.mjs.map +0 -1
- package/dist/fleet-scripts.d.mts.map +0 -1
- package/dist/fleet-scripts.mjs +0 -41
- package/dist/fleet-scripts.mjs.map +0 -1
- package/dist/git-safe-env.d.mts.map +0 -1
- package/dist/git-safe-env.mjs +0 -44
- package/dist/git-safe-env.mjs.map +0 -1
- package/dist/guest-run.d.mts.map +0 -1
- package/dist/guest-run.mjs +0 -164
- package/dist/guest-run.mjs.map +0 -1
- package/dist/host-address.d.mts.map +0 -1
- package/dist/host-address.mjs.map +0 -1
- package/dist/host-capacity.d.mts.map +0 -1
- package/dist/host-capacity.mjs.map +0 -1
- package/dist/host-metrics.d.mts.map +0 -1
- package/dist/host-metrics.mjs +0 -201
- package/dist/host-metrics.mjs.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -25
- package/dist/index.js.map +0 -1
- package/dist/local-vm.d.ts.map +0 -1
- package/dist/local-vm.js +0 -360
- package/dist/local-vm.js.map +0 -1
- package/dist/measure-guard.d.mts.map +0 -1
- package/dist/measure-guard.mjs +0 -73
- package/dist/measure-guard.mjs.map +0 -1
- package/dist/normalise-fleet.d.mts.map +0 -1
- package/dist/normalise-fleet.mjs +0 -76
- package/dist/normalise-fleet.mjs.map +0 -1
- package/dist/npm-cli-executable.d.mts.map +0 -1
- package/dist/npm-cli-executable.mjs +0 -159
- package/dist/npm-cli-executable.mjs.map +0 -1
- package/dist/probe-outcome.d.mts.map +0 -1
- package/dist/probe-outcome.mjs.map +0 -1
- package/dist/protocol-guard.d.mts.map +0 -1
- package/dist/protocol-guard.mjs +0 -121
- package/dist/protocol-guard.mjs.map +0 -1
- package/dist/source-walk.d.mts.map +0 -1
- package/dist/source-walk.mjs +0 -56
- package/dist/source-walk.mjs.map +0 -1
- package/dist/transient-fault.d.mts.map +0 -1
- package/dist/transient-fault.mjs.map +0 -1
- package/dist/utm-deprecated.d.mts.map +0 -1
- package/dist/utm-deprecated.mjs +0 -23
- package/dist/utm-deprecated.mjs.map +0 -1
- package/dist/worker-code-check.d.mts.map +0 -1
- package/dist/worker-code-check.mjs.map +0 -1
- package/dist/worker-health.d.mts.map +0 -1
- package/dist/worker-health.mjs.map +0 -1
- package/dist/worker-http.d.mts.map +0 -1
- package/dist/worker-http.mjs.map +0 -1
- package/dist/worker-stats.d.mts.map +0 -1
- package/dist/worker-stats.mjs +0 -143
- package/dist/worker-stats.mjs.map +0 -1
|
@@ -1,436 +1,212 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
{
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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({
|
|
97
|
+
fields.coverage.push({
|
|
98
|
+
field,
|
|
99
|
+
reported,
|
|
100
|
+
asked
|
|
101
|
+
});
|
|
278
102
|
}
|
|
279
|
-
if (distinctValues(reading) > 1)
|
|
280
|
-
|
|
103
|
+
if (distinctValues(reading) > 1) mismatches.push({
|
|
104
|
+
field,
|
|
105
|
+
why,
|
|
106
|
+
values
|
|
107
|
+
});
|
|
281
108
|
}
|
|
282
|
-
return {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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 })
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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 })
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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])
|
|
380
|
-
if (
|
|
381
|
-
|
|
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
|
-
|
|
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])
|
|
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)
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
212
|
+
export { MUST_MATCH, POLICY_MUST_MATCH, REPORTED_ONLY, describeMismatches, describeReportedOnly, fleetConsistency };
|
package/dist/fleet-env.d.mts
CHANGED