@a11ign/screenreader-fleet 0.0.0-reserved.0 → 0.1.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 (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +94 -2
  3. package/dist/capture-client.d.mts +49 -0
  4. package/dist/capture-client.d.mts.map +1 -0
  5. package/dist/capture-client.mjs +352 -0
  6. package/dist/capture-client.mjs.map +1 -0
  7. package/dist/check-worker-code.d.mts +34 -0
  8. package/dist/check-worker-code.d.mts.map +1 -0
  9. package/dist/check-worker-code.mjs +173 -0
  10. package/dist/check-worker-code.mjs.map +1 -0
  11. package/dist/cli-flags.d.mts +71 -0
  12. package/dist/cli-flags.d.mts.map +1 -0
  13. package/dist/cli-flags.mjs +207 -0
  14. package/dist/cli-flags.mjs.map +1 -0
  15. package/dist/code-drift.d.mts +140 -0
  16. package/dist/code-drift.d.mts.map +1 -0
  17. package/dist/code-drift.mjs +284 -0
  18. package/dist/code-drift.mjs.map +1 -0
  19. package/dist/command-line-census.d.mts +33 -0
  20. package/dist/command-line-census.d.mts.map +1 -0
  21. package/dist/command-line-census.mjs +96 -0
  22. package/dist/command-line-census.mjs.map +1 -0
  23. package/dist/compare-workers.d.mts +3 -0
  24. package/dist/compare-workers.d.mts.map +1 -0
  25. package/dist/compare-workers.mjs +332 -0
  26. package/dist/compare-workers.mjs.map +1 -0
  27. package/dist/control-plane-isolation.d.mts +45 -0
  28. package/dist/control-plane-isolation.d.mts.map +1 -0
  29. package/dist/control-plane-isolation.mjs +67 -0
  30. package/dist/control-plane-isolation.mjs.map +1 -0
  31. package/dist/deploy-worker.d.mts +3 -0
  32. package/dist/deploy-worker.d.mts.map +1 -0
  33. package/dist/deploy-worker.mjs +333 -0
  34. package/dist/deploy-worker.mjs.map +1 -0
  35. package/dist/doctor.d.mts +216 -0
  36. package/dist/doctor.d.mts.map +1 -0
  37. package/dist/doctor.mjs +962 -0
  38. package/dist/doctor.mjs.map +1 -0
  39. package/dist/fleet-consistency.d.mts +235 -0
  40. package/dist/fleet-consistency.d.mts.map +1 -0
  41. package/dist/fleet-consistency.mjs +436 -0
  42. package/dist/fleet-consistency.mjs.map +1 -0
  43. package/dist/fleet-env.d.mts +228 -0
  44. package/dist/fleet-env.d.mts.map +1 -0
  45. package/dist/fleet-env.mjs +509 -0
  46. package/dist/fleet-env.mjs.map +1 -0
  47. package/dist/fleet-scripts.d.mts +11 -0
  48. package/dist/fleet-scripts.d.mts.map +1 -0
  49. package/dist/fleet-scripts.mjs +41 -0
  50. package/dist/fleet-scripts.mjs.map +1 -0
  51. package/dist/git-safe-env.d.mts +10 -0
  52. package/dist/git-safe-env.d.mts.map +1 -0
  53. package/dist/git-safe-env.mjs +44 -0
  54. package/dist/git-safe-env.mjs.map +1 -0
  55. package/dist/guest-run.d.mts +26 -0
  56. package/dist/guest-run.d.mts.map +1 -0
  57. package/dist/guest-run.mjs +164 -0
  58. package/dist/guest-run.mjs.map +1 -0
  59. package/dist/host-address.d.mts +33 -0
  60. package/dist/host-address.d.mts.map +1 -0
  61. package/dist/host-address.mjs +105 -0
  62. package/dist/host-address.mjs.map +1 -0
  63. package/dist/host-capacity.d.mts +64 -0
  64. package/dist/host-capacity.d.mts.map +1 -0
  65. package/dist/host-capacity.mjs +152 -0
  66. package/dist/host-capacity.mjs.map +1 -0
  67. package/dist/host-metrics.d.mts +116 -0
  68. package/dist/host-metrics.d.mts.map +1 -0
  69. package/dist/host-metrics.mjs +201 -0
  70. package/dist/host-metrics.mjs.map +1 -0
  71. package/dist/index.d.ts +23 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +25 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/local-vm.d.ts +125 -0
  76. package/dist/local-vm.d.ts.map +1 -0
  77. package/dist/local-vm.js +360 -0
  78. package/dist/local-vm.js.map +1 -0
  79. package/dist/measure-guard.d.mts +34 -0
  80. package/dist/measure-guard.d.mts.map +1 -0
  81. package/dist/measure-guard.mjs +73 -0
  82. package/dist/measure-guard.mjs.map +1 -0
  83. package/dist/normalise-fleet.d.mts +2 -0
  84. package/dist/normalise-fleet.d.mts.map +1 -0
  85. package/dist/normalise-fleet.mjs +76 -0
  86. package/dist/normalise-fleet.mjs.map +1 -0
  87. package/dist/npm-cli-executable.d.mts +42 -0
  88. package/dist/npm-cli-executable.d.mts.map +1 -0
  89. package/dist/npm-cli-executable.mjs +159 -0
  90. package/dist/npm-cli-executable.mjs.map +1 -0
  91. package/dist/probe-outcome.d.mts +89 -0
  92. package/dist/probe-outcome.d.mts.map +1 -0
  93. package/dist/probe-outcome.mjs +104 -0
  94. package/dist/probe-outcome.mjs.map +1 -0
  95. package/dist/protocol-guard.d.mts +34 -0
  96. package/dist/protocol-guard.d.mts.map +1 -0
  97. package/dist/protocol-guard.mjs +121 -0
  98. package/dist/protocol-guard.mjs.map +1 -0
  99. package/dist/source-walk.d.mts +12 -0
  100. package/dist/source-walk.d.mts.map +1 -0
  101. package/dist/source-walk.mjs +56 -0
  102. package/dist/source-walk.mjs.map +1 -0
  103. package/dist/transient-fault.d.mts +6 -0
  104. package/dist/transient-fault.d.mts.map +1 -0
  105. package/dist/transient-fault.mjs +86 -0
  106. package/dist/transient-fault.mjs.map +1 -0
  107. package/dist/utm-deprecated.d.mts +6 -0
  108. package/dist/utm-deprecated.d.mts.map +1 -0
  109. package/dist/utm-deprecated.mjs +23 -0
  110. package/dist/utm-deprecated.mjs.map +1 -0
  111. package/dist/worker-code-check.d.mts +29 -0
  112. package/dist/worker-code-check.d.mts.map +1 -0
  113. package/dist/worker-code-check.mjs +78 -0
  114. package/dist/worker-code-check.mjs.map +1 -0
  115. package/dist/worker-health.d.mts +56 -0
  116. package/dist/worker-health.d.mts.map +1 -0
  117. package/dist/worker-health.mjs +73 -0
  118. package/dist/worker-health.mjs.map +1 -0
  119. package/dist/worker-http.d.mts +103 -0
  120. package/dist/worker-http.d.mts.map +1 -0
  121. package/dist/worker-http.mjs +277 -0
  122. package/dist/worker-http.mjs.map +1 -0
  123. package/dist/worker-stats.d.mts +66 -0
  124. package/dist/worker-stats.d.mts.map +1 -0
  125. package/dist/worker-stats.mjs +143 -0
  126. package/dist/worker-stats.mjs.map +1 -0
  127. package/package.json +96 -4
  128. package/src/local-worker/autounattend.xml +280 -0
  129. package/src/local-worker/build-vm.sh +218 -0
  130. package/src/local-worker/clone-worker.sh +141 -0
  131. package/src/local-worker/create-utm-vm.sh +202 -0
  132. package/src/local-worker/fetch-windows-iso.sh +238 -0
  133. package/src/local-worker/first-boot.cmd +58 -0
  134. package/src/local-worker/worker-ctl.sh +442 -0
  135. package/src/provisioning/README.md +28 -0
  136. package/src/provisioning/apply-foreground-lock-timeout.ps1 +71 -0
  137. package/src/provisioning/bare-metal/README.md +213 -0
  138. package/src/provisioning/bare-metal/a11y-bootstrap.service +58 -0
  139. package/src/provisioning/bare-metal/autounattend.xml +428 -0
  140. package/src/provisioning/bare-metal/serve-bootstrap.sh +86 -0
  141. package/src/provisioning/bootstrap-control-plane.sh +463 -0
  142. package/src/provisioning/bootstrap-windows-worker.ps1 +649 -0
  143. package/src/provisioning/build-lean-worker-image.ps1 +275 -0
  144. package/src/provisioning/diagnose-nvda-worker.ps1 +174 -0
  145. package/src/provisioning/provision-nvda-worker.ps1 +827 -0
  146. package/src/provisioning/set-display-mode.ps1 +411 -0
  147. package/src/provisioning/stamp-provision-revision.ps1 +184 -0
@@ -0,0 +1,159 @@
1
+ // A DELIBERATE, DISCLOSED DUPLICATE of `scripts/npm-cli-executable.mjs` at the repo root.
2
+ //
3
+ // #492: never spawn `npx`/`npm` at all -- resolve npm's own CLI script and run it through
4
+ // `process.execPath`, since Node's CVE-2024-27980 fix permanently made `spawn`/`spawnSync`/`execFileSync`
5
+ // refuse to launch a `.bat`/`.cmd` file directly without `shell` (and `ceo`'s ruling is no `shell: true`,
6
+ // anywhere). See `scripts/npm-cli-executable.mjs`'s own header for the full incident and the two-layout
7
+ // resolution this file duplicates behaviourally.
8
+ //
9
+ // This package publishes `check-worker-code.mjs` and `deploy-worker.mjs` as `bin` entries
10
+ // (`package.json`), so every file they import -- `doctor.mjs` among them -- ships in the published
11
+ // tarball and can only import from INSIDE `@a11ign/screenreader-fleet`. The repo-root `scripts/` directory
12
+ // does not exist once this package is installed from npm, so importing it here would work in this
13
+ // monorepo and break for every real consumer. That is the entire reason this file exists rather than a
14
+ // relative import to the root: not stylistic preference, a publish-boundary constraint (see ADR 0004,
15
+ // and `git-safe-env.mjs`'s own header beside this file for the identical shape).
16
+ //
17
+ // #2890: `pnpmCliInvocation` (#2301) and the two helpers it uses are copied verbatim, because `doctor.mjs`
18
+ // spawns pnpm now, not npm; the npm half stays so the pin below keeps comparing whole files.
19
+ //
20
+ // Kept behaviourally identical to `scripts/npm-cli-executable.mjs` and pinned equal to it by
21
+ // `npm-cli-executable.test.ts`, which is this repo's own remedy #3 ("pin them equal with a test") for a
22
+ // fact that CANNOT be stated once because the two copies cross a package-publishing boundary neither can
23
+ // import through.
24
+ import { existsSync, realpathSync } from "node:fs";
25
+ import { join, dirname, delimiter } from "node:path";
26
+ /**
27
+ * @param {"npx" | "npm"} name
28
+ * @returns {string}
29
+ */
30
+ function cliScriptName(name) {
31
+ return name === "npx" ? "npx-cli.js" : "npm-cli.js";
32
+ }
33
+ /**
34
+ * @param {"npx" | "npm"} name
35
+ * @returns {string[]}
36
+ */
37
+ export function npmCliScriptCandidates(name) {
38
+ const script = cliScriptName(name);
39
+ const nodeDir = dirname(process.execPath);
40
+ const fixed = [
41
+ join(nodeDir, "node_modules", "npm", "bin", script),
42
+ join(nodeDir, "..", "lib", "node_modules", "npm", "bin", script),
43
+ ];
44
+ const fromPath = pathDerivedCandidate(name, script);
45
+ return fromPath === null ? fixed : [...fixed, fromPath];
46
+ }
47
+ /**
48
+ * #1268: THE THIRD LAYOUT. Debian and Ubuntu package npm at `/usr/share/nodejs/npm/bin/`, nowhere near
49
+ * `node`, and put `/usr/bin/npm` and `/usr/bin/npx` on PATH as symlinks straight to the CLI scripts. So
50
+ * the executable on PATH, symlinks resolved, IS the script (Debian) or sits in the script's directory
51
+ * (the upstream tarball's `bin/npx` wrapper). Tried LAST so the two fixed layouts keep their order on
52
+ * the platforms they were measured on; `null` when nothing named `name` is on PATH, so the candidate
53
+ * list never carries a path that cannot exist. Found by the first `npm ci` on the agents host.
54
+ * @param {"npx" | "npm"} name
55
+ * @param {string} script
56
+ * @returns {string | null}
57
+ */
58
+ function pathDerivedCandidate(name, script) {
59
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
60
+ if (dir === "")
61
+ continue;
62
+ const executable = join(dir, name);
63
+ if (!existsSync(executable))
64
+ continue;
65
+ const real = realpathSync(executable);
66
+ return real.endsWith(script) ? real : join(dirname(real), script);
67
+ }
68
+ return null;
69
+ }
70
+ /**
71
+ * @param {"npx" | "npm"} name
72
+ * @returns {string}
73
+ */
74
+ export function resolveNpmCliScript(name) {
75
+ const candidates = npmCliScriptCandidates(name);
76
+ const found = candidates.find((path) => existsSync(path));
77
+ if (!found) {
78
+ throw new Error(`could not find npm's own ${cliScriptName(name)} beside this Node install -- tried:\n`
79
+ + candidates.map((path) => ` ${path}`).join("\n"));
80
+ }
81
+ return found;
82
+ }
83
+ /**
84
+ * @param {"npx" | "npm"} name
85
+ * @param {string[]} args
86
+ * @returns {{ command: string, args: string[] }}
87
+ */
88
+ export function npmCliInvocation(name, args) {
89
+ return { command: process.execPath, args: [resolveNpmCliScript(name), ...args] };
90
+ }
91
+ /**
92
+ * #2301: `pnpm <args>` WITHOUT SPAWNING A `.cmd`, for the same reason `npmCliInvocation` exists, and with the
93
+ * added problem that pnpm is not always ON `PATH` at all: this host and the Windows workers run it as
94
+ * `corepack pnpm` (`packageManager` in the root manifest pins the version), while a CI runner has the
95
+ * shim `pnpm/action-setup` puts there. Three ways to reach it, tried in this order, each ending in an argv
96
+ * that `execFileSync` can run with no shell:
97
+ *
98
+ * 1. `npm_execpath`, when this process was itself started BY pnpm (`pnpm run ...`, `pnpm exec ...`): pnpm
99
+ * says which script it is, so nothing is searched for and no other pnpm can be picked up by mistake.
100
+ * 2. a `pnpm` on `PATH`: the executable itself on POSIX; on Windows the shim is `pnpm.cmd`, so the
101
+ * `pnpm.cjs` beside it is run through `process.execPath` instead.
102
+ * 3. `corepack` on `PATH`, the same way: `corepack pnpm` on POSIX, `corepack.js` beside `node.exe`
103
+ * through `process.execPath` on Windows.
104
+ *
105
+ * Throws NAMING WHAT WAS TRIED, for the reason `resolveNpmCliScript` does.
106
+ * @param {string[]} args
107
+ * @returns {{ command: string, args: string[] }}
108
+ */
109
+ export function pnpmCliInvocation(args) {
110
+ const fromParent = process.env.npm_execpath ?? "";
111
+ if (/pnpm\.c?js$/.test(fromParent) && existsSync(fromParent)) {
112
+ return { command: process.execPath, args: [fromParent, ...args] };
113
+ }
114
+ const shim = onPath("pnpm");
115
+ if (shim !== null)
116
+ return shimInvocation(shim, join("node_modules", "pnpm", "bin", "pnpm.cjs"), args);
117
+ const corepack = onPath("corepack");
118
+ if (corepack !== null) {
119
+ return shimInvocation(corepack, join("node_modules", "corepack", "dist", "corepack.js"), ["pnpm", ...args]);
120
+ }
121
+ throw new Error("could not find pnpm: `npm_execpath` is not a pnpm script, and neither `pnpm` nor `corepack` "
122
+ + "is on PATH. `packageManager` in the root package.json names the version -- `corepack enable` or "
123
+ + "`pnpm/action-setup` provides it.");
124
+ }
125
+ /**
126
+ * The first executable called `name` on PATH, or `null`. On Windows the executable is `name.cmd`, which
127
+ * `existsSync` finds only when the extension is tried, so both spellings are.
128
+ * @param {string} name
129
+ * @returns {string | null}
130
+ */
131
+ function onPath(name) {
132
+ for (const dir of (process.env.PATH ?? "").split(delimiter)) {
133
+ if (dir === "")
134
+ continue;
135
+ for (const candidate of [join(dir, name), join(dir, `${name}.cmd`)]) {
136
+ if (existsSync(candidate))
137
+ return candidate;
138
+ }
139
+ }
140
+ return null;
141
+ }
142
+ /**
143
+ * A POSIX shim is spawned as it is; a `.cmd` shim is never spawned (CVE-2024-27980, see the header), so the
144
+ * package script it wraps is run through `process.execPath`, found beside the shim (the layout `pnpm add -g`
145
+ * and `pnpm/action-setup` share) or beside `node` (corepack's).
146
+ * @param {string} shim
147
+ * @param {string} script the wrapped script, relative to the shim's directory or to `node`'s
148
+ * @param {string[]} args
149
+ * @returns {{ command: string, args: string[] }}
150
+ */
151
+ function shimInvocation(shim, script, args) {
152
+ if (!shim.endsWith(".cmd"))
153
+ return { command: shim, args };
154
+ const found = [join(dirname(shim), script), join(dirname(process.execPath), script)].find((path) => existsSync(path));
155
+ if (found === undefined)
156
+ throw new Error(`${shim} is a .cmd shim and its script ${script} is not beside it or beside node`);
157
+ return { command: process.execPath, args: [found, ...args] };
158
+ }
159
+ //# sourceMappingURL=npm-cli-executable.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"npm-cli-executable.mjs","sourceRoot":"","sources":["../src/npm-cli-executable.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @param {string} worker the worker's base URL
3
+ * @param {{ timeoutMs?: number, request?: ProbeRequest }} [options]
4
+ * @returns {Promise<Probe>}
5
+ */
6
+ export function probeHealth(worker: string, { timeoutMs, request }?: {
7
+ timeoutMs?: number;
8
+ request?: ProbeRequest;
9
+ }): Promise<Probe>;
10
+ /**
11
+ * One sentence per outcome, in the words of what was OBSERVED. Only `refused`, `not-ready` and `busy` say the box is
12
+ * up, because only those are things the box itself said; a silent probe says "did not answer" and never "down".
13
+ *
14
+ * @param {Probe} probe
15
+ * @param {{ worker: string, timeoutMs?: number }} about
16
+ * @returns {string}
17
+ */
18
+ export function describeProbe(probe: Probe, { worker, timeoutMs }: {
19
+ worker: string;
20
+ timeoutMs?: number;
21
+ }): string;
22
+ /** @typedef {typeof requestJson} ProbeRequest */
23
+ /**
24
+ * THE PER-PROBE TIMEOUT, WITH ITS READING. A probe that outlives it is UNKNOWN, never "down", so this number
25
+ * decides how much slowness a healthy box is allowed.
26
+ *
27
+ * All of it READ by others on the real fleet (`orchestrator`, #2671) and none measured by this row, whose
28
+ * engineer is barred from probing it:
29
+ * - the slowest HEALTHY box: 2.80 to 3.09 s on a11y-worker-13, -14 and -16, twelve others 0.53 to 0.76 s;
30
+ * - that is the FIRST answer after the box has been quiet more than 5 s, because the worker rebuilds its
31
+ * environment block with two synchronous `powershell.exe` calls when it is older than that, so a probe made
32
+ * by hand is nearly always the slow case;
33
+ * - a LOADED box (one that has just stopped a capture) can take up to about 10 s: each of the two calls is
34
+ * bounded at 5 s and they stop the worker's event loop for the whole time.
35
+ * 12 s is that loaded ceiling plus 2 s, the number `fleet-wake.mjs` `HEALTH_TIMEOUT_MS` states for the same
36
+ * reading. Before #2683 these entries used 5 s (`witness`, 1.91 s over the 3.09 s box and none over a loaded
37
+ * one), 8 s (`auth:leak-check`) and 10 s (`worker:compare`'s busy guard), read at `d119fb0f2`. Cost of the
38
+ * generosity: a box that really is off costs one 12 s wait before the message. A refusal costs nothing, it comes
39
+ * back at once.
40
+ */
41
+ export const WORKER_PROBE_TIMEOUT_MS: 12000;
42
+ /** How to wake a box from a checkout of this repo. The published entries do not do it themselves. */
43
+ export const WAKE_HINT: string;
44
+ /**
45
+ * WHAT ONE `/health` PROBE CAN SAY, for the entries that reach a worker from the PUBLISHED side and by hand
46
+ * (`witness`, `worker:compare`, `auth:leak-check`, #2683 of #2655).
47
+ *
48
+ * Imports `worker-http` and nothing else: no `control` (this package is published and `@a11ign/control` never
49
+ * is, `worker-fleet-does-not-read-control.test.ts`) and no corpus reader, so a test may import it without
50
+ * pulling the corpus closure in. These entries do NOT wake a box (ADR 0012, product-manager 2026-09-26): they
51
+ * say it did not answer, and name the command that wakes one. `packages/control/src/fleet-wake.mjs` keeps its
52
+ * own `probeWorker` with the same five outcomes because it is the unpublished side of that line.
53
+ *
54
+ * The outcomes, and the one distinction that matters (an unanswered probe is UNKNOWN, never "down"):
55
+ *
56
+ * ready the box's own report, `ready: true`
57
+ * busy the box's own report, `busy: true`: a capture is running. Up, and not free
58
+ * not-ready it answered and its own `ready:false` says why, or it answered a non-2xx status. UP
59
+ * refused the connection was refused, so something answered the TCP handshake with a reset: the BOX IS UP
60
+ * and the worker is not listening. UP
61
+ * no-answer nothing came back inside the timeout (`timedOut`), or the transport failed some other way
62
+ * (unreachable, reset). One silent probe cannot separate "off" from "slow" from "the path dropped
63
+ * it", so it is never called down
64
+ *
65
+ * `health` is the body the box sent, on the three outcomes where one arrived, because "usable" is a different
66
+ * question in different entries (`workerIsUsable` counts a worker predating the `ready` field as usable; the
67
+ * measurement guard wants `ready: true`) and this module does not choose for them.
68
+ */
69
+ export type Probe = {
70
+ outcome: "ready";
71
+ health: any;
72
+ } | {
73
+ outcome: "busy";
74
+ health: any;
75
+ } | {
76
+ outcome: "not-ready";
77
+ reason: string;
78
+ health: any;
79
+ } | {
80
+ outcome: "refused";
81
+ message: string;
82
+ } | {
83
+ outcome: "no-answer";
84
+ message: string;
85
+ timedOut: boolean;
86
+ };
87
+ export type ProbeRequest = typeof requestJson;
88
+ import { requestJson } from "./worker-http.mjs";
89
+ //# sourceMappingURL=probe-outcome.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe-outcome.d.mts","sourceRoot":"","sources":["../src/probe-outcome.mjs"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,oCAJW,MAAM,2BACN;IAAE,SAAS,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,YAAY,CAAA;CAAE,GAC5C,OAAO,CAAC,KAAK,CAAC,CAmB1B;AAMD;;;;;;;GAOG;AACH,qCAJW,KAAK,yBACL;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC,MAAM,CAalB;AArED,iDAAiD;AAEjD;;;;;;;;;;;;;;;;;GAiBG;AACH,sCAAuC,KAAM,CAAC;AA0B9C,qGAAqG;AACrG,+BAC6E;;;;;;;;;;;;;;;;;;;;;;;;;;oBAvDhE;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GAC1E;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,GAAG,CAAA;CAAE,GACrD;IAAE,OAAO,EAAE,SAAS,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GACvC;IAAE,OAAO,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,OAAO,CAAA;CAAE;2BAIrD,OAAO,WAAW;4BAFJ,mBAAmB"}
@@ -0,0 +1,104 @@
1
+ // @ts-check
2
+ /**
3
+ * WHAT ONE `/health` PROBE CAN SAY, for the entries that reach a worker from the PUBLISHED side and by hand
4
+ * (`witness`, `worker:compare`, `auth:leak-check`, #2683 of #2655).
5
+ *
6
+ * Imports `worker-http` and nothing else: no `control` (this package is published and `@a11ign/control` never
7
+ * is, `worker-fleet-does-not-read-control.test.ts`) and no corpus reader, so a test may import it without
8
+ * pulling the corpus closure in. These entries do NOT wake a box (ADR 0012, product-manager 2026-09-26): they
9
+ * say it did not answer, and name the command that wakes one. `packages/control/src/fleet-wake.mjs` keeps its
10
+ * own `probeWorker` with the same five outcomes because it is the unpublished side of that line.
11
+ *
12
+ * The outcomes, and the one distinction that matters (an unanswered probe is UNKNOWN, never "down"):
13
+ *
14
+ * ready the box's own report, `ready: true`
15
+ * busy the box's own report, `busy: true`: a capture is running. Up, and not free
16
+ * not-ready it answered and its own `ready:false` says why, or it answered a non-2xx status. UP
17
+ * refused the connection was refused, so something answered the TCP handshake with a reset: the BOX IS UP
18
+ * and the worker is not listening. UP
19
+ * no-answer nothing came back inside the timeout (`timedOut`), or the transport failed some other way
20
+ * (unreachable, reset). One silent probe cannot separate "off" from "slow" from "the path dropped
21
+ * it", so it is never called down
22
+ *
23
+ * `health` is the body the box sent, on the three outcomes where one arrived, because "usable" is a different
24
+ * question in different entries (`workerIsUsable` counts a worker predating the `ready` field as usable; the
25
+ * measurement guard wants `ready: true`) and this module does not choose for them.
26
+ *
27
+ * @typedef {{ outcome: "ready", health: any } | { outcome: "busy", health: any }
28
+ * | { outcome: "not-ready", reason: string, health: any }
29
+ * | { outcome: "refused", message: string }
30
+ * | { outcome: "no-answer", message: string, timedOut: boolean }} Probe
31
+ */
32
+ import { requestJson } from "./worker-http.mjs";
33
+ /** @typedef {typeof requestJson} ProbeRequest */
34
+ /**
35
+ * THE PER-PROBE TIMEOUT, WITH ITS READING. A probe that outlives it is UNKNOWN, never "down", so this number
36
+ * decides how much slowness a healthy box is allowed.
37
+ *
38
+ * All of it READ by others on the real fleet (`orchestrator`, #2671) and none measured by this row, whose
39
+ * engineer is barred from probing it:
40
+ * - the slowest HEALTHY box: 2.80 to 3.09 s on a11y-worker-13, -14 and -16, twelve others 0.53 to 0.76 s;
41
+ * - that is the FIRST answer after the box has been quiet more than 5 s, because the worker rebuilds its
42
+ * environment block with two synchronous `powershell.exe` calls when it is older than that, so a probe made
43
+ * by hand is nearly always the slow case;
44
+ * - a LOADED box (one that has just stopped a capture) can take up to about 10 s: each of the two calls is
45
+ * bounded at 5 s and they stop the worker's event loop for the whole time.
46
+ * 12 s is that loaded ceiling plus 2 s, the number `fleet-wake.mjs` `HEALTH_TIMEOUT_MS` states for the same
47
+ * reading. Before #2683 these entries used 5 s (`witness`, 1.91 s over the 3.09 s box and none over a loaded
48
+ * one), 8 s (`auth:leak-check`) and 10 s (`worker:compare`'s busy guard), read at `d119fb0f2`. Cost of the
49
+ * generosity: a box that really is off costs one 12 s wait before the message. A refusal costs nothing, it comes
50
+ * back at once.
51
+ */
52
+ export const WORKER_PROBE_TIMEOUT_MS = 12_000;
53
+ /**
54
+ * @param {string} worker the worker's base URL
55
+ * @param {{ timeoutMs?: number, request?: ProbeRequest }} [options]
56
+ * @returns {Promise<Probe>}
57
+ */
58
+ export async function probeHealth(worker, { timeoutMs = WORKER_PROBE_TIMEOUT_MS, request = requestJson } = {}) {
59
+ let response;
60
+ try {
61
+ response = await request(`${worker.replace(/\/$/, "")}/health`, { timeoutMs });
62
+ }
63
+ catch (error) {
64
+ const { code, message } = /** @type {NodeJS.ErrnoException} */ (error);
65
+ // `message` is EMPTY for a raw ECONNREFUSED on this Node version, so it falls back to the code.
66
+ if (code === "ECONNREFUSED")
67
+ return { outcome: "refused", message: message || code };
68
+ return { outcome: "no-answer", timedOut: code === "ETIMEDOUT",
69
+ message: `${code ? `${code}: ` : ""}${message}` };
70
+ }
71
+ const health = response.json;
72
+ if (!response.ok)
73
+ return { outcome: "not-ready", reason: `/health answered HTTP ${response.status}`, health };
74
+ if (health?.busy === true)
75
+ return { outcome: "busy", health };
76
+ if (health?.ready === true)
77
+ return { outcome: "ready", health };
78
+ return { outcome: "not-ready", health,
79
+ reason: response.json?.reason ?? "/health answered without `ready: true` and without a reason" };
80
+ }
81
+ /** How to wake a box from a checkout of this repo. The published entries do not do it themselves. */
82
+ export const WAKE_HINT = "If it is a fleet box that has gone to sleep, wake it from a checkout of the a11ign repo with "
83
+ + "`npm run fleet:wake -- <name>` (<name> is its entry in inventory.yml).";
84
+ /**
85
+ * One sentence per outcome, in the words of what was OBSERVED. Only `refused`, `not-ready` and `busy` say the box is
86
+ * up, because only those are things the box itself said; a silent probe says "did not answer" and never "down".
87
+ *
88
+ * @param {Probe} probe
89
+ * @param {{ worker: string, timeoutMs?: number }} about
90
+ * @returns {string}
91
+ */
92
+ export function describeProbe(probe, { worker, timeoutMs = WORKER_PROBE_TIMEOUT_MS }) {
93
+ switch (probe.outcome) {
94
+ case "ready": return `${worker} answered and is ready.`;
95
+ case "busy": return `${worker} is up and busy with a capture.`;
96
+ case "not-ready": return `${worker} is up and answered, but it says it is not ready: ${probe.reason}.`;
97
+ case "refused": return `${worker} refused the connection (${probe.message}): the machine is up and the worker `
98
+ + "is not listening. Waking it will not help; start the worker on it.";
99
+ case "no-answer": return `${worker} did not answer${probe.timedOut ? ` within ${timeoutMs / 1000} s` : ""} `
100
+ + `(${probe.message}). That does not say it is off: it may be asleep, slow, or not reachable from here. ${WAKE_HINT}`;
101
+ default: throw new Error(`unknown probe outcome: ${JSON.stringify(probe)}`);
102
+ }
103
+ }
104
+ //# sourceMappingURL=probe-outcome.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe-outcome.mjs","sourceRoot":"","sources":["../src/probe-outcome.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Decide whether a deploy may proceed.
3
+ *
4
+ * @param {{ local: number|string|null, served: {worker: string, protocol: number|string|null}[],
5
+ * allowed: boolean, source?: string }} input
6
+ * @returns {{ refuse: boolean, message: string }}
7
+ */
8
+ export function protocolVerdict({ local, served, allowed, source }: {
9
+ local: number | string | null;
10
+ served: {
11
+ worker: string;
12
+ protocol: number | string | null;
13
+ }[];
14
+ allowed: boolean;
15
+ source?: string;
16
+ }): {
17
+ refuse: boolean;
18
+ message: string;
19
+ };
20
+ /**
21
+ * Ask each worker which protocol it serves.
22
+ *
23
+ * A worker that cannot be reached reports `null` rather than being dropped, so the caller can tell "the
24
+ * fleet agrees" from "we could not ask" — the distinction the verdict above turns on.
25
+ *
26
+ * @param {string[]} urls
27
+ * @returns {Promise<{worker: string, protocol: number|string|null}[]>}
28
+ */
29
+ export function servedProtocols(urls: string[]): Promise<{
30
+ worker: string;
31
+ protocol: number | string | null;
32
+ }[]>;
33
+ export const RECAPTURE_COST: string;
34
+ //# sourceMappingURL=protocol-guard.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol-guard.d.mts","sourceRoot":"","sources":["../src/protocol-guard.mjs"],"names":[],"mappings":"AAqDA;;;;;;GAMG;AACH,oEAJW;IAAE,KAAK,EAAE,MAAM,GAAC,MAAM,GAAC,IAAI,CAAC;IAAC,MAAM,EAAE;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,GAAC,MAAM,GAAC,IAAI,CAAA;KAAC,EAAE,CAAC;IACpF,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAmChD;AAED;;;;;;;;GAQG;AACH,sCAHW,MAAM,EAAE,GACN,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAC,MAAM,GAAC,IAAI,CAAA;CAAC,EAAE,CAAC,CAqBrE;AA9ED,oCAG6G"}
@@ -0,0 +1,121 @@
1
+ /**
2
+ * REFUSE A DEPLOY THAT WOULD SILENTLY INVALIDATE THE CORPUS.
3
+ *
4
+ * `CAPTURE_PROTOCOL_VERSION` is a capture-cache key. Shipping a change to it invalidates every cached
5
+ * capture — `RECAPTURE_COST` below says how many a full re-run produces — and that is sometimes exactly
6
+ * what you want. What must never happen is it going out without somebody deciding.
7
+ *
8
+ * **The guard existed and reached only the DEPRECATED path.** `deploy-worker.mjs` refuses without
9
+ * `--allow-protocol-change`, and that script is `utmctl file push` against a VM UUID: it cannot reach a
10
+ * bare-metal worker and fails immediately off macOS. Every box in `inventory.yml` is bare metal and
11
+ * deploys through `fleet:deploy`, which had no such check. So the only live deploy path was the unguarded
12
+ * one — this repo's signature defect (a remedy that reaches one call site when the behaviour reaches
13
+ * several) with the remedy on the path nobody uses.
14
+ *
15
+ * **This asks the FLEET, not git, and that is the stronger question.** The UTM guard compares the working
16
+ * tree against HEAD, which answers "did I commit the bump". Useful, and not the thing at risk: what
17
+ * decides whether the cache survives is whether the version about to ship differs from the one the boxes
18
+ * are *already serving*, because that is the version every cached capture was stamped under. Reading it
19
+ * from `/health` also means the check shares no failure mode with the action — the deploy goes over SSH,
20
+ * the verification over HTTP — which is this repo's rule after a hash-check that returned EMPTY whenever
21
+ * the channel it used was broken, and read as a flaky tool rather than a failed deploy.
22
+ */
23
+ import { requestJson } from "./worker-http.mjs";
24
+ /**
25
+ * WHAT A FULL RECAPTURE COSTS, in the words the two deploy refusals print -- derived here and shown, not
26
+ * retyped (#2244). The refusals printed 2,122, a count from 2026-07-26; on 2026-09-23 the cache held 4,500
27
+ * captures across 1,715 cases.
28
+ *
29
+ * **The population is what a full RE-RUN produces, not what the cache holds.** A run captures each case
30
+ * `manifest.json` lists, twice; the cache holds 4,500 captures because it also holds cases the manifest no
31
+ * longer lists, and a protocol bump does not make those cost anything, since nothing will ask for them
32
+ * again. So 1,715 x 2 = 3,430 is the price an operator is deciding on, and 4,500 is not.
33
+ *
34
+ * The inputs are `orchestrator`'s reading on the corpus host, 2026-09-23T14:26Z (#1561 comment
35
+ * 5796655391), and are not re-measured here. This file cannot read the manifest -- the control host that
36
+ * runs the guard does not have the corpus -- so the reading is dated in the message itself, where a stale
37
+ * one is visible to the person who acts on it.
38
+ *
39
+ * **NO WALL-CLOCK.** This used to say "about four hours of fleet time". That was a rate times the 1,061-case
40
+ * corpus of 2026-07-26 on a fleet of a size nothing in this file can read, and #2155 dropped its own estimates for the same
41
+ * reason. The rate is what `npm run fleet:status` can show; the product is somebody's to time.
42
+ */
43
+ const MANIFEST_CASES = 1715;
44
+ const CAPTURES_PER_CASE = 2;
45
+ export const RECAPTURE_COST = `${(MANIFEST_CASES * CAPTURES_PER_CASE).toLocaleString("en-US")} captures `
46
+ + `(${MANIFEST_CASES.toLocaleString("en-US")} cases in manifest.json x ${CAPTURES_PER_CASE}, read `
47
+ + "2026-09-23T14:26Z; how long that takes depends on the fleet, so time a run rather than trust a figure)";
48
+ /** How long a worker gets to answer `/health` before it counts as unreachable. */
49
+ const HEALTH_TIMEOUT_MS = 5_000;
50
+ /**
51
+ * Decide whether a deploy may proceed.
52
+ *
53
+ * @param {{ local: number|string|null, served: {worker: string, protocol: number|string|null}[],
54
+ * allowed: boolean, source?: string }} input
55
+ * @returns {{ refuse: boolean, message: string }}
56
+ */
57
+ export function protocolVerdict({ local, served, allowed, source }) {
58
+ if (local === null || local === undefined) {
59
+ // NAMES THE FILE IT WAS GIVEN, never a hardcoded one. This message said "from capture-core.mjs" while
60
+ // the caller had long since been pointed elsewhere, so the one sentence a broken guard prints sent the
61
+ // reader to a file that was not the problem.
62
+ return { refuse: true, message: `cannot read CAPTURE_PROTOCOL_VERSION from ${source ?? "the worker "
63
+ + "source"}, so this deploy cannot say whether it would invalidate the corpus. That is a broken `
64
+ + "guard, not a clean one." };
65
+ }
66
+ const reachable = served.filter((s) => s.protocol !== null && s.protocol !== undefined);
67
+ // EXAMINED NOTHING IS NOT AGREEMENT. If no worker answered, the guard has no opinion — and a guard with
68
+ // no opinion that reports success is the `evidence:check` defect: it exited 0 saying "evidence unchanged"
69
+ // having compared 2 of 48, because its own guard covered only `compared === 0` and called that "the
70
+ // extreme case rather than a different one". Nothing here is the extreme case of that; it IS that.
71
+ if (reachable.length === 0) {
72
+ return { refuse: !allowed, message: "no worker answered /health, so nothing could say which "
73
+ + `CAPTURE_PROTOCOL_VERSION the fleet is serving. Local is ${local}. Deploying blind could invalidate `
74
+ + "every cached capture. Check `npm run fleet:status`, or pass --allow-protocol-change if you mean it." };
75
+ }
76
+ const differing = reachable.filter((s) => String(s.protocol) !== String(local));
77
+ if (differing.length === 0)
78
+ return { refuse: false, message: "" };
79
+ const detail = differing.map((s) => ` ${s.worker} serves ${s.protocol}`).join("\n");
80
+ if (allowed) {
81
+ return { refuse: false, message: `\n Deploying CAPTURE_PROTOCOL_VERSION ${local} as requested.\n${detail}\n`
82
+ + " Every cached capture becomes invalid and the next run recaptures all of them.\n" };
83
+ }
84
+ return { refuse: true, message: `\nREFUSING TO DEPLOY: this checkout has CAPTURE_PROTOCOL_VERSION = ${local}, and the fleet does not.\n\n`
85
+ + `${detail}\n\n`
86
+ + "That value is a capture-cache key, so deploying it invalidates every cached capture and forces a\n"
87
+ + `full recapture: ${RECAPTURE_COST}.\n\n`
88
+ + " If that is what you want: npm run fleet:deploy -- --allow-protocol-change\n"
89
+ + " If it is not: check what changed in packages/nvda-worker/src/capture-core.mjs\n" };
90
+ }
91
+ /**
92
+ * Ask each worker which protocol it serves.
93
+ *
94
+ * A worker that cannot be reached reports `null` rather than being dropped, so the caller can tell "the
95
+ * fleet agrees" from "we could not ask" — the distinction the verdict above turns on.
96
+ *
97
+ * @param {string[]} urls
98
+ * @returns {Promise<{worker: string, protocol: number|string|null}[]>}
99
+ */
100
+ export async function servedProtocols(urls) {
101
+ return Promise.all(urls.map(async (url) => {
102
+ try {
103
+ // `requestJson`, not `fetch` -- the same worker JSON API this repo's own audit found duplicated with
104
+ // its own timeout mechanism at several other call sites (worker-http.mjs's own header), and this one
105
+ // is on the deploy-guard path: a swallowed truncation here would misreport a fleet mid-protocol-bump
106
+ // as unreachable rather than as differing, silently weakening the one check standing between a
107
+ // deploy and an unannounced cache wipe.
108
+ const response = await requestJson(`${url.replace(/\/$/, "")}/health`, { timeoutMs: HEALTH_TIMEOUT_MS });
109
+ // `?? null` and never `?? 0`: a worker predating the field has no opinion, and reading absence as a
110
+ // number is the defect this project pays for most often. `response.json` is `undefined` rather than
111
+ // a throw on unparseable JSON (requestJson's own contract), which `?.` already treats as "no opinion".
112
+ return { worker: url, protocol: response.json?.environment?.captureProtocol ?? null };
113
+ }
114
+ catch {
115
+ // Deliberately not rethrown: unreachable is a VERDICT here, handled by `protocolVerdict`, not an
116
+ // error. Swallowing it would be wrong only if nothing downstream distinguished it, and something does.
117
+ return { worker: url, protocol: null };
118
+ }
119
+ }));
120
+ }
121
+ //# sourceMappingURL=protocol-guard.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protocol-guard.mjs","sourceRoot":"","sources":["../src/protocol-guard.mjs"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Every non-test source file under `packages/`, as `[relativePath, source]`.
3
+ *
4
+ * @param {{ root?: string }} [options]
5
+ * @returns {Array<[string, string]>}
6
+ */
7
+ export function sourceFiles({ root }?: {
8
+ root?: string;
9
+ }): Array<[string, string]>;
10
+ /** The `packages/` directory, resolved from this module rather than from the caller's cwd. */
11
+ export const PACKAGES: string;
12
+ //# sourceMappingURL=source-walk.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source-walk.d.mts","sourceRoot":"","sources":["../src/source-walk.mjs"],"names":[],"mappings":"AAiCA;;;;;GAKG;AACH,uCAHW;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GACf,KAAK,CAAC,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAkBnC;AA3BD,8FAA8F;AAC9F,8BAAkF"}
@@ -0,0 +1,56 @@
1
+ // @ts-check
2
+ /**
3
+ * Every source file under `packages/`, so a guard can be written over what EXISTS rather than over a list.
4
+ *
5
+ * This repo's most expensive recurring shape is a check that only examines the places somebody already
6
+ * thought of. Two instances are recorded in `budget-ladder.test.ts` alone: the worker-file list that let a
7
+ * file deploy invisibly, and the ladder guard that read ONE hardcoded path and therefore could not see
8
+ * `capture-real-pages.mjs` declaring 300 s against a 520 s hard timeout — inverted, on the client that
9
+ * needed it most.
10
+ *
11
+ * Extracted here because there were about to be TWO copies of the walk: one for capture budgets and one for
12
+ * `--worker` validation. A duplicated discovery is a discovery that drifts, which is the same defect one
13
+ * level up.
14
+ *
15
+ * `dist` is excluded because it is build output of the very files being checked, so including it
16
+ * double-counts and reports a stale copy as a violation after the source has been fixed. Test files are
17
+ * excluded because a guard asserting on other guards' source is noise.
18
+ *
19
+ * Dot-directories are skipped, as `.gitignore` would, because this walk reads the FILESYSTEM and a test may
20
+ * plant a transient one inside the repository (`corpus-backup.test.ts` does, as `.corpus-backup-mutation-*`,
21
+ * so a copied script can resolve `node_modules`). Listing it and reading the file after the other test has
22
+ * removed it failed `pnpm run verify` with ENOENT (#3337, after #2876 and #1919). No tracked source lives in
23
+ * one, and a dot-directory is not a place a guard's population should come from.
24
+ */
25
+ import { readFileSync, readdirSync } from "node:fs";
26
+ import { join, dirname } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ /** The `packages/` directory, resolved from this module rather than from the caller's cwd. */
29
+ export const PACKAGES = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
30
+ const SKIPPED_DIRECTORY = /^(node_modules|dist|\..*)$/;
31
+ /**
32
+ * Every non-test source file under `packages/`, as `[relativePath, source]`.
33
+ *
34
+ * @param {{ root?: string }} [options]
35
+ * @returns {Array<[string, string]>}
36
+ */
37
+ export function sourceFiles({ root = PACKAGES } = {}) {
38
+ /** @type {Array<[string, string]>} */
39
+ const found = [];
40
+ /** @param {string} dir */
41
+ const walk = (dir) => {
42
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
43
+ const path = join(dir, entry.name);
44
+ if (entry.isDirectory()) {
45
+ if (!SKIPPED_DIRECTORY.test(entry.name))
46
+ walk(path);
47
+ }
48
+ else if (/\.(mjs|ts)$/.test(entry.name) && !entry.name.includes(".test.")) {
49
+ found.push([path.slice(root.length + 1), readFileSync(path, "utf8")]);
50
+ }
51
+ }
52
+ };
53
+ walk(root);
54
+ return found;
55
+ }
56
+ //# sourceMappingURL=source-walk.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"source-walk.mjs","sourceRoot":"","sources":["../src/source-walk.mjs"],"names":[],"mappings":"AAAA,YAAY;AACZ;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,8FAA8F;AAC9F,MAAM,CAAC,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AAElF,MAAM,iBAAiB,GAAG,4BAA4B,CAAC;AAEvD;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,EAAE,IAAI,GAAG,QAAQ,EAAE,GAAG,EAAE;IAClD,sCAAsC;IACtC,MAAM,KAAK,GAAG,EAAE,CAAC;IACjB,0BAA0B;IAC1B,MAAM,IAAI,GAAG,CAAC,GAAG,EAAE,EAAE;QACnB,KAAK,MAAM,KAAK,IAAI,WAAW,CAAC,GAAG,EAAE,EAAE,aAAa,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;YAC9D,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACnC,IAAI,KAAK,CAAC,WAAW,EAAE,EAAE,CAAC;gBACxB,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC;YACtD,CAAC;iBAAM,IAAI,aAAa,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAC5E,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,EAAE,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC;YACxE,CAAC;QACH,CAAC;IACH,CAAC,CAAC;IACF,IAAI,CAAC,IAAI,CAAC,CAAC;IACX,OAAO,KAAK,CAAC;AACf,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @param {unknown} error anything a failed request threw — a node:http Error, an undici one, a string
3
+ * @returns {boolean}
4
+ */
5
+ export function isTransient(error: unknown): boolean;
6
+ //# sourceMappingURL=transient-fault.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transient-fault.d.mts","sourceRoot":"","sources":["../src/transient-fault.mjs"],"names":[],"mappings":"AAsEA;;;GAGG;AACH,mCAHW,OAAO,GACL,OAAO,CAanB"}