@a11ign/screenreader-fleet 0.5.2 → 0.6.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 (49) hide show
  1. package/README.md +37 -0
  2. package/dist/{capture-client.d.mts → capture-client.d.ts} +2 -2
  3. package/dist/{check-worker-code.d.mts → check-worker-code.d.ts} +2 -3
  4. package/dist/check-worker-code.mjs +1 -1
  5. package/dist/{cli-flags.d.mts → cli-flags.d.ts} +5 -5
  6. package/dist/{code-drift.d.mts → code-drift.d.ts} +7 -7
  7. package/dist/{command-line-census.d.mts → command-line-census.d.ts} +3 -3
  8. package/dist/{compare-workers.d.mts → compare-workers.d.ts} +1 -1
  9. package/dist/{control-plane-isolation.d.mts → control-plane-isolation.d.ts} +1 -1
  10. package/dist/{doctor.d.mts → doctor.d.ts} +57 -31
  11. package/dist/doctor.mjs +4 -4
  12. package/dist/{fleet-consistency.d.mts → fleet-consistency.d.ts} +50 -85
  13. package/dist/fleet-consistency.mjs +4 -0
  14. package/dist/{fleet-env.d.mts → fleet-env.d.ts} +45 -45
  15. package/dist/{fleet-scripts.d.mts → fleet-scripts.d.ts} +1 -1
  16. package/dist/{git-safe-env.d.mts → git-safe-env.d.ts} +3 -3
  17. package/dist/{guest-run.d.mts → guest-run.d.ts} +5 -5
  18. package/dist/{host-address.d.mts → host-address.d.ts} +4 -4
  19. package/dist/{host-capacity.d.mts → host-capacity.d.ts} +5 -5
  20. package/dist/{host-metrics.d.mts → host-metrics.d.ts} +23 -15
  21. package/dist/index.mjs +1 -1
  22. package/dist/{measure-guard.d.mts → measure-guard.d.ts} +14 -14
  23. package/dist/{npm-cli-executable.d.mts → npm-cli-executable.d.ts} +4 -4
  24. package/dist/{probe-outcome.d.mts → probe-outcome.d.ts} +43 -44
  25. package/dist/{protocol-guard.d.mts → protocol-guard.d.ts} +3 -3
  26. package/dist/source-walk.d.ts +11 -0
  27. package/dist/{transient-fault.d.mts → transient-fault.d.ts} +1 -1
  28. package/dist/tsx-import.d.ts +6 -0
  29. package/dist/{utm-deprecated.d.mts → utm-deprecated.d.ts} +1 -1
  30. package/dist/worker-code-check.d.ts +95 -0
  31. package/dist/worker-code-check.mjs +1 -1
  32. package/dist/{worker-health.d.mts → worker-health.d.ts} +2 -2
  33. package/dist/{worker-http.d.mts → worker-http.d.ts} +51 -51
  34. package/dist/{worker-stats.d.mts → worker-stats.d.ts} +40 -20
  35. package/package.json +30 -19
  36. package/src/local-worker/build-vm.sh +2 -2
  37. package/src/local-worker/clone-worker.sh +1 -1
  38. package/src/local-worker/create-utm-vm.sh +1 -1
  39. package/src/local-worker/fetch-windows-iso.sh +5 -5
  40. package/src/local-worker/worker-ctl.sh +12 -12
  41. package/src/provisioning/apply-foreground-lock-timeout.ps1 +1 -1
  42. package/src/provisioning/bootstrap-windows-worker.ps1 +1 -1
  43. package/src/provisioning/diagnose-nvda-worker.ps1 +1 -1
  44. package/src/provisioning/stamp-provision-revision.ps1 +3 -3
  45. package/dist/source-walk.d.mts +0 -11
  46. package/dist/worker-code-check.d.mts +0 -54
  47. /package/dist/{normalise-fleet.d.mts → normalise-fleet.d.ts} +0 -0
  48. /package/dist/{src_fleet-scripts_mjs.mjs → src_fleet-scripts_ts.mjs} +0 -0
  49. /package/dist/{src_git-safe-env_mjs.mjs → src_git-safe-env_ts.mjs} +0 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Is the fleet running the code this checkout expects — asked BEFORE a capture run, not after it.
3
+ *
4
+ * ## The hole this closes
5
+ *
6
+ * `run-job.yml` refuses to run at a commit other than the one asked for, and the comment above that
7
+ * refusal says why: *"a job that quietly runs four commits behind reports success for code you did not
8
+ * ask for."* That guard covers the LAB. It says nothing about the twelve machines that actually take the
9
+ * captures, and those are a second checkout, deployed by a separate command nobody is forced to run.
10
+ *
11
+ * So a capture run could be dispatched at the right commit, on a lab that proved it was at the right
12
+ * commit, and still capture with the PREVIOUS release of `capture-core.mjs`. Measured on 2026-08-25: after
13
+ * `MAX_TAB_STOPS` went 12 -> 150 and `collectByType` started recording `prevCount`, the real-page corpus
14
+ * held both populations at once, and the only way to read it was to bucket captures by whether they
15
+ * carried the new diagnostic mark at all. The evidence was mixed, the run reported success, and the
16
+ * separation had to be done by hand afterwards.
17
+ *
18
+ * `npm run worker:code` has answered this question correctly the whole time. It is a separate command a
19
+ * human must remember, which is this repo's own definition of a check that does not happen — and it was
20
+ * remembered by hand four times in one day before this existed.
21
+ *
22
+ * ## Why a REFUSAL, and why on any difference at all
23
+ *
24
+ * `workerCode` is deliberately outside the capture cache key ("it changes when a comment changes, and
25
+ * invalidating the WHOLE corpus over a reworded comment is how a cache becomes something people turn
26
+ * off") and deliberately outside
27
+ * `fleet-consistency.mjs`'s `MUST_MATCH` for the same reason. Both of those are the right call for
28
+ * the questions they answer — *is this evidence still valid* and *are these guests interchangeable*.
29
+ *
30
+ * This is a third question with a different answer: *am I about to capture with the code I asked for*. A
31
+ * comment-only drift is a false alarm here and it costs one `fleet:deploy`; a real drift costs a corpus and
32
+ * is invisible, because nothing downstream keys on `workerCode`. That asymmetry is the whole argument.
33
+ *
34
+ * It is a PRECONDITION and never a key: nothing here invalidates a cached capture.
35
+ *
36
+ * ## The comparison itself lives in `code-drift.mjs`, and this file is the reason for the split
37
+ *
38
+ * `resolveExpectedWorkerCode` below needs `codeVersion`/`workerSourceDir`, reached through a SUBPATH export
39
+ * (`@a11ign/screenreader-worker/code-version`) rather than a relative path — a relative one drags
40
+ * `nvda-worker`'s `.mjs` files into this package's own tsc project and the build dies with TS5055 ("would
41
+ * overwrite input file"). That subpath resolves through `node_modules`, which is exactly what
42
+ * `packages/control` does not have (ADR 0012) — so when `lab-job.mjs` needed this same comparison BEFORE
43
+ * dispatching to the lab, it could not import this file. `code-drift.mjs` is the part of this file with no
44
+ * opinion about what "expected" means: it takes the hash as a parameter, imports nothing but
45
+ * `node:child_process`, and is safe from both places. This file supplies the one thing only it can compute.
46
+ */
47
+ import { codeDrift, describeCodeDrift, describeEmptyPool, readWorkerCode, remedyLines, workerSourceDirty } from "./code-drift.js";
48
+ /**
49
+ * The hash every worker is expected to be serving, and WHERE IT CAME FROM. ONE function, asked by `a11ign-worker-code` and by
50
+ * `assertFleetRunsThisCheckout` alike, so the two cannot be given different hashers again (a11ign/a11ign#3781).
51
+ *
52
+ * With a layer clone present it is the CLONE's: the clone's own `code-version.mjs` over the clone's `src/`, which is what
53
+ * `layerCodeVersion("nvda-worker")` computes for the deploy and the lab, and what a guest is told to be on (`--layer-ref`). Asking the
54
+ * installed package instead made every worker read stale the first time a guest was deployed at a sha whose `.mjs` differed from the
55
+ * release, with a remedy ("redeploy") that could not clear it.
56
+ *
57
+ * With none it is the installed package's (a11ign/a11ign#3740: the one the package was RELEASED with, `codeVersion()` and no
58
+ * directory, because the built package's `workerSourceDir()` is `dist/` and holds none of the files a guest runs), and `source` and
59
+ * `note` say so, so a reading is never silent about which it was.
60
+ *
61
+ * ASYNC because the clone's hasher can only be imported dynamically, as `layerCodeVersion` does.
62
+ *
63
+ * @param {{ checkoutRoot?: string }} [options]
64
+ * @returns {Promise<{ code: string, source: "clone" | "installed", sourceDir: string, note: string }>}
65
+ */
66
+ export declare function resolveExpectedWorkerCode({ checkoutRoot }?: {
67
+ checkoutRoot?: string;
68
+ }): Promise<{
69
+ code: string;
70
+ source: "clone" | "installed";
71
+ sourceDir: string;
72
+ note: string;
73
+ }>;
74
+ export { codeDrift, describeCodeDrift, describeEmptyPool, readWorkerCode, remedyLines, workerSourceDirty };
75
+ /**
76
+ * Refuse to capture with a fleet that is not running this checkout.
77
+ *
78
+ * Called at the boundary of every capture entry point, for the reason `assertWorkerUrl` is: the
79
+ * alternative is discovering it in the evidence weeks later, where a stale worker looks like a page that
80
+ * changed. **Both entry points, not one** — a remedy that reaches one of several paths is the shape this
81
+ * repo has paid for three times over (`anchorToTop`, `ensureSpeechChannel`, `waitForAnnouncement`), and
82
+ * `capture-preflight.test.ts` pins that both call it.
83
+ *
84
+ * A thin wrapper over `assertWorkersServe`, supplying the one thing only this file can compute: the hash.
85
+ *
86
+ * @param {string[]} workers
87
+ * @param {{when?: string, allow?: boolean, read?: (url: string) => Promise<string|null>, bareMetalUrls?: string[], checkoutRoot?: string}} options
88
+ */
89
+ export declare function assertFleetRunsThisCheckout(workers: string[], options?: {
90
+ when?: string;
91
+ allow?: boolean;
92
+ read?: (url: string) => Promise<string | null>;
93
+ bareMetalUrls?: string[];
94
+ checkoutRoot?: string;
95
+ }): Promise<void>;
@@ -4,7 +4,7 @@ import { existsSync, readFileSync } from "node:fs";
4
4
  import { join, resolve } from "node:path";
5
5
  import { pathToFileURL } from "node:url";
6
6
  import { requestJson } from "./worker-http.mjs";
7
- import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
7
+ import { sandboxGitEnv } from "./src_git-safe-env_ts.mjs";
8
8
  const HEALTH_TIMEOUT_MS = 15000;
9
9
  function codeDrift(expected, readings) {
10
10
  const stale = [];
@@ -36,7 +36,7 @@
36
36
  * @param {{ busy?: boolean, ready?: boolean } | null | undefined} health
37
37
  * @returns {boolean}
38
38
  */
39
- export function workerIsUsable(health: {
39
+ export declare function workerIsUsable(health: {
40
40
  busy?: boolean;
41
41
  ready?: boolean;
42
42
  } | null | undefined): boolean;
@@ -44,7 +44,7 @@ export function workerIsUsable(health: {
44
44
  * @param {{ captures?: number, recoveries?: number, failures?: number } | null | undefined} vitals
45
45
  * @returns {{ degraded: boolean, reason: string | null, recoveryShare: number | null }}
46
46
  */
47
- export function assessWorker(vitals: {
47
+ export declare function assessWorker(vitals: {
48
48
  captures?: number;
49
49
  recoveries?: number;
50
50
  failures?: number;
@@ -1,3 +1,52 @@
1
+ /**
2
+ * How long a CLIENT should wait for a capture. One definition, because five had drifted.
3
+ *
4
+ * It must exceed the worker's own hard timeout (`CAPTURE_HARD_TIMEOUT_DEFAULT_MS`, 520 s) or the client
5
+ * gives up first, and a capture the worker would have completed is reported as a client failure. Five
6
+ * clients sat at 300 s -- `compare-workers`, `bench-capture`, `evidence-check`, `repeat-capture` and
7
+ * `capture-real-pages` -- against that 520 s. On the generated corpus nothing noticed, because a 1,338-byte
8
+ * page finishes in seconds. On REAL pages it silently dropped whatever used its budget, biasing the
9
+ * real-page corpus toward small simple pages: precisely the axis that corpus exists to add.
10
+ *
11
+ * 560,000 -> 620,000 on architecture-audit.md §14.5: `runCapture` (server.mjs) spends up to
12
+ * `DESKTOP_PREPARE_TIMEOUT_MS` (60 s) clearing the desktop BEFORE the hard-timeout-wrapped capture attempt
13
+ * even starts, sequentially rather than overlapping it -- so the true worst case a worker can legitimately
14
+ * take is 60 s + 520 s = 580 s, not 520 s alone. The old 560 s ceiling sat BELOW that, so a real page that
15
+ * used the full prepare budget and the full capture budget was killed by the CLIENT first and reported as
16
+ * a client failure for work the worker would have finished -- the exact shape this constant already exists
17
+ * to prevent, one rung further out. 620,000 keeps the same 40 s margin above the new true worst case that
18
+ * the original 560,000 kept above 520,000. `budget-ladder.test.ts` asserts the full sequence, not only the
19
+ * capture attempt inside it.
20
+ *
21
+ * Deliberately NOT imported from `@a11ign/screenreader-worker`: this package runs on macOS and Linux and must
22
+ * not depend on a win32-only one. `budget-ladder.test.ts` enforces the relationship instead, over every
23
+ * client it DISCOVERS rather than a list -- which is how the 300 s clients stayed invisible while a guard
24
+ * for exactly this existed and read one hardcoded path.
25
+ *
26
+ * `DATASET_CAPTURE_TIMEOUT_MS` still overrides it in the dataset runner, which is the only client that
27
+ * wants a per-run ceiling.
28
+ */
29
+ export declare const CAPTURE_CLIENT_TIMEOUT_MS = 620000;
30
+ /**
31
+ * How long a capture's silent connection may idle before the OS proves it is still there.
32
+ *
33
+ * 15 s, and RAISING IT TO 60 s WAS TRIED AND REVERTED — by this file's own test, which refuses a delay
34
+ * above ~30 s because common NAT idle timeouts start there.
35
+ *
36
+ * The reasoning for raising it was that the async path removed the long-lived connection, so nothing needs
37
+ * an aggressive value. That is true of the async path and FALSE of the `A11Y_SYNC_CAPTURE` escape hatch,
38
+ * which still holds one socket silent for a whole capture. At 60 s the first probe would fire after the
39
+ * reap, so the hatch would be unprotected while the constant looked deliberate — a guard weakened for a
40
+ * reason that did not cover the case it exists for.
41
+ *
42
+ * It stays aggressive until item D explains why THIS path reaps in seconds when the literature says
43
+ * minutes. An unexplained number is not a solved problem.
44
+ *
45
+ * EXPORTED so its test can key on the exact value. Node's own HTTP SERVER calls `setKeepAlive(true, 5000)`
46
+ * on every socket it accepts, so a test that merely looked for "keepalive with a plausible delay" matched
47
+ * the server's call and passed with this hook DELETED — found by mutation, not by reading.
48
+ */
49
+ export declare const KEEPALIVE_DELAY_MS = 15000;
1
50
  /**
2
51
  * A worker address, validated at the BOUNDARY where it enters the program.
3
52
  *
@@ -26,7 +75,7 @@
26
75
  * @param {{ source?: string }} [options] what to name in the error, e.g. "--worker"
27
76
  * @returns {string} the address, trailing slash removed
28
77
  */
29
- export function assertWorkerUrl(value: string | null | undefined, { source }?: {
78
+ export declare function assertWorkerUrl(value: string | null | undefined, { source }?: {
30
79
  source?: string;
31
80
  }): string;
32
81
  /**
@@ -41,7 +90,7 @@ export function assertWorkerUrl(value: string | null | undefined, { source }?: {
41
90
  * strictly worse than saying the value is untyped. The SHAPE that matters is checked where it is
42
91
  * defined: `capture-core`'s `Capture` typedef, and the worker's own `/health` contract.
43
92
  */
44
- export function requestJson(url: string, { method, body, timeoutMs }?: {
93
+ export declare function requestJson(url: string, { method, body, timeoutMs }?: {
45
94
  method?: string;
46
95
  body?: unknown;
47
96
  timeoutMs?: number;
@@ -51,52 +100,3 @@ export function requestJson(url: string, { method, body, timeoutMs }?: {
51
100
  text: string;
52
101
  json: any;
53
102
  }>;
54
- /**
55
- * How long a CLIENT should wait for a capture. One definition, because five had drifted.
56
- *
57
- * It must exceed the worker's own hard timeout (`CAPTURE_HARD_TIMEOUT_DEFAULT_MS`, 520 s) or the client
58
- * gives up first, and a capture the worker would have completed is reported as a client failure. Five
59
- * clients sat at 300 s -- `compare-workers`, `bench-capture`, `evidence-check`, `repeat-capture` and
60
- * `capture-real-pages` -- against that 520 s. On the generated corpus nothing noticed, because a 1,338-byte
61
- * page finishes in seconds. On REAL pages it silently dropped whatever used its budget, biasing the
62
- * real-page corpus toward small simple pages: precisely the axis that corpus exists to add.
63
- *
64
- * 560,000 -> 620,000 on architecture-audit.md §14.5: `runCapture` (server.mjs) spends up to
65
- * `DESKTOP_PREPARE_TIMEOUT_MS` (60 s) clearing the desktop BEFORE the hard-timeout-wrapped capture attempt
66
- * even starts, sequentially rather than overlapping it -- so the true worst case a worker can legitimately
67
- * take is 60 s + 520 s = 580 s, not 520 s alone. The old 560 s ceiling sat BELOW that, so a real page that
68
- * used the full prepare budget and the full capture budget was killed by the CLIENT first and reported as
69
- * a client failure for work the worker would have finished -- the exact shape this constant already exists
70
- * to prevent, one rung further out. 620,000 keeps the same 40 s margin above the new true worst case that
71
- * the original 560,000 kept above 520,000. `budget-ladder.test.ts` asserts the full sequence, not only the
72
- * capture attempt inside it.
73
- *
74
- * Deliberately NOT imported from `@a11ign/screenreader-worker`: this package runs on macOS and Linux and must
75
- * not depend on a win32-only one. `budget-ladder.test.ts` enforces the relationship instead, over every
76
- * client it DISCOVERS rather than a list -- which is how the 300 s clients stayed invisible while a guard
77
- * for exactly this existed and read one hardcoded path.
78
- *
79
- * `DATASET_CAPTURE_TIMEOUT_MS` still overrides it in the dataset runner, which is the only client that
80
- * wants a per-run ceiling.
81
- */
82
- export const CAPTURE_CLIENT_TIMEOUT_MS: 620000;
83
- /**
84
- * How long a capture's silent connection may idle before the OS proves it is still there.
85
- *
86
- * 15 s, and RAISING IT TO 60 s WAS TRIED AND REVERTED — by this file's own test, which refuses a delay
87
- * above ~30 s because common NAT idle timeouts start there.
88
- *
89
- * The reasoning for raising it was that the async path removed the long-lived connection, so nothing needs
90
- * an aggressive value. That is true of the async path and FALSE of the `A11Y_SYNC_CAPTURE` escape hatch,
91
- * which still holds one socket silent for a whole capture. At 60 s the first probe would fire after the
92
- * reap, so the hatch would be unprotected while the constant looked deliberate — a guard weakened for a
93
- * reason that did not cover the case it exists for.
94
- *
95
- * It stays aggressive until item D explains why THIS path reaps in seconds when the literature says
96
- * minutes. An unexplained number is not a solved problem.
97
- *
98
- * EXPORTED so its test can key on the exact value. Node's own HTTP SERVER calls `setKeepAlive(true, 5000)`
99
- * on every socket it accepts, so a test that merely looked for "keepalive with a plausible delay" matched
100
- * the server's call and passed with this hook DELETED — found by mutation, not by reading.
101
- */
102
- export const KEEPALIVE_DELAY_MS: 15000;
@@ -1,6 +1,42 @@
1
+ /**
2
+ * Robust statistics for comparing workers, and an explicit refusal to call a difference real when the
3
+ * samples do not support it.
4
+ *
5
+ * This module exists because of a specific, repeated mistake: concluding from one measurement. Over one
6
+ * session a 2x difference between two guests was attributed, in turn, to the display being blanked, to
7
+ * in-memory accumulation, to background CPU, to Edge's launch, to the Edge profile, to the sweep, and to
8
+ * Edge's startup boost. Every one of those was a single measurement, and every one was wrong.
9
+ *
10
+ * Two things caused that, and both are fixed here rather than in a comment.
11
+ *
12
+ * **Means lied.** One 63 s outlier in an eight-run sample moved the mean by 5 s and made a healthy
13
+ * worker look broken. Capture time has a long right tail by nature — a mute screen reader costs ~86 s
14
+ * against a normal ~12 s — so the mean measures how often the tail was hit, not what a capture costs.
15
+ * The median and the interquartile range do not move when one run goes bad.
16
+ *
17
+ * **Sequential sampling confounded.** Measuring worker A for five minutes and then worker B for five
18
+ * minutes attributes any drift in the host during those ten minutes to the difference between the
19
+ * workers. Interleaving round-robin makes drift common to all of them.
20
+ */
21
+ /**
22
+ * One worker's samples, summarised.
23
+ *
24
+ * Named as a typedef rather than repeated inline because four functions pass it around, and this module's
25
+ * whole purpose is refusing to claim a difference the samples do not support — so `q1`, `q3` and `iqr`
26
+ * travelling together, as one thing with one name, is the point rather than a formality.
27
+ */
28
+ export type Summary = {
29
+ n: number;
30
+ median: number;
31
+ q1: number;
32
+ q3: number;
33
+ iqr: number;
34
+ min: number;
35
+ max: number;
36
+ };
1
37
  /** Linear-interpolated quantile. Fine for the sample sizes here and has no dependencies. */
2
38
  /** @param {number[]} values @param {number} q @returns {number|null} */
3
- export function quantile(values: number[], q: number): number | null;
39
+ export declare function quantile(values: number[], q: number): number | null;
4
40
  /**
5
41
  * The shape of one worker's samples.
6
42
  *
@@ -11,7 +47,7 @@ export function quantile(values: number[], q: number): number | null;
11
47
  * @param {number[]} values
12
48
  * @returns {Summary|null}
13
49
  */
14
- export function describe(values: number[]): Summary | null;
50
+ export declare function describe(values: number[]): Summary | null;
15
51
  /**
16
52
  * Compare workers and say, conservatively, whether a difference is supported.
17
53
  *
@@ -25,7 +61,7 @@ export function describe(values: number[]): Summary | null;
25
61
  * @returns {{ stats: Record<string, Summary>, slowest: string|null, fastest: string|null,
26
62
  * distinguishable: boolean, verdict: string }}
27
63
  */
28
- export function compareWorkers(samplesByWorker: Record<string, number[]>): {
64
+ export declare function compareWorkers(samplesByWorker: Record<string, number[]>): {
29
65
  stats: Record<string, Summary>;
30
66
  slowest: string | null;
31
67
  fastest: string | null;
@@ -43,23 +79,7 @@ export function compareWorkers(samplesByWorker: Record<string, number[]>): {
43
79
  * @returns {Record<string, number | null>} null where the worker captured nothing — "no idea", which
44
80
  * must not be confused with a rate of zero ("perfectly reliable").
45
81
  */
46
- export function recoveryRates(deltas: Record<string, {
82
+ export declare function recoveryRates(deltas: Record<string, {
47
83
  recoveries: number;
48
84
  captures: number;
49
85
  }>): Record<string, number | null>;
50
- /**
51
- * One worker's samples, summarised.
52
- *
53
- * Named as a typedef rather than repeated inline because four functions pass it around, and this module's
54
- * whole purpose is refusing to claim a difference the samples do not support — so `q1`, `q3` and `iqr`
55
- * travelling together, as one thing with one name, is the point rather than a formality.
56
- */
57
- export type Summary = {
58
- n: number;
59
- median: number;
60
- q1: number;
61
- q3: number;
62
- iqr: number;
63
- min: number;
64
- max: number;
65
- };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11ign/screenreader-fleet",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Host-side lifecycle, health and capacity for a fleet of Windows NVDA capture workers: lease one, judge whether it is degrading, and know how many the host can afford.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -10,47 +10,47 @@
10
10
  "default": "./dist/index.mjs"
11
11
  },
12
12
  "./health": {
13
- "types": "./dist/worker-health.d.mts",
13
+ "types": "./dist/worker-health.d.ts",
14
14
  "default": "./dist/worker-health.mjs"
15
15
  },
16
16
  "./capacity": {
17
- "types": "./dist/host-capacity.d.mts",
17
+ "types": "./dist/host-capacity.d.ts",
18
18
  "default": "./dist/host-capacity.mjs"
19
19
  },
20
20
  "./probe-outcome": {
21
- "types": "./dist/probe-outcome.d.mts",
21
+ "types": "./dist/probe-outcome.d.ts",
22
22
  "default": "./dist/probe-outcome.mjs"
23
23
  },
24
24
  "./worker-http": {
25
- "types": "./dist/worker-http.d.mts",
25
+ "types": "./dist/worker-http.d.ts",
26
26
  "default": "./dist/worker-http.mjs"
27
27
  },
28
28
  "./cli-flags": {
29
- "types": "./dist/cli-flags.d.mts",
29
+ "types": "./dist/cli-flags.d.ts",
30
30
  "default": "./dist/cli-flags.mjs"
31
31
  },
32
32
  "./transient-fault": {
33
- "types": "./dist/transient-fault.d.mts",
33
+ "types": "./dist/transient-fault.d.ts",
34
34
  "default": "./dist/transient-fault.mjs"
35
35
  },
36
36
  "./capture-client": {
37
- "types": "./dist/capture-client.d.mts",
37
+ "types": "./dist/capture-client.d.ts",
38
38
  "default": "./dist/capture-client.mjs"
39
39
  },
40
40
  "./host-address": {
41
- "types": "./dist/host-address.d.mts",
41
+ "types": "./dist/host-address.d.ts",
42
42
  "default": "./dist/host-address.mjs"
43
43
  },
44
44
  "./fleet-env": {
45
- "types": "./dist/fleet-env.d.mts",
45
+ "types": "./dist/fleet-env.d.ts",
46
46
  "default": "./dist/fleet-env.mjs"
47
47
  },
48
48
  "./fleet-consistency": {
49
- "types": "./dist/fleet-consistency.d.mts",
49
+ "types": "./dist/fleet-consistency.d.ts",
50
50
  "default": "./dist/fleet-consistency.mjs"
51
51
  },
52
52
  "./worker-code-check": {
53
- "types": "./dist/worker-code-check.d.mts",
53
+ "types": "./dist/worker-code-check.d.ts",
54
54
  "default": "./dist/worker-code-check.mjs"
55
55
  }
56
56
  },
@@ -73,9 +73,18 @@
73
73
  },
74
74
  "devDependencies": {
75
75
  "@a11ign/evidence": "0.1.0",
76
- "@a11ign/toolchain": "0.1.4",
76
+ "@a11ign/toolchain": "0.3.1",
77
+ "@changesets/cli": "3.0.3",
78
+ "@eslint/js": "^10.0.1",
77
79
  "@rslib/core": "1.0.3",
80
+ "@rstest/core": "0.12.3",
81
+ "@types/node": "^26.6.4",
82
+ "eslint": "^10.12.0",
83
+ "globals": "^17.13.0",
84
+ "jiti": "^2.7.0",
85
+ "tsx": "^4.23.15",
78
86
  "typescript": "^6.0.3",
87
+ "typescript-eslint": "^8.71.1",
79
88
  "yaml": "^2.9.1"
80
89
  },
81
90
  "engines": {
@@ -84,11 +93,6 @@
84
93
  "publishConfig": {
85
94
  "access": "public"
86
95
  },
87
- "repository": {
88
- "type": "git",
89
- "url": "git+https://github.com/a11ign/screenreader-fleet.git",
90
- "directory": "packages/worker-fleet"
91
- },
92
96
  "homepage": "https://github.com/a11ign/screenreader-fleet",
93
97
  "keywords": [
94
98
  "accessibility",
@@ -99,7 +103,14 @@
99
103
  "utm",
100
104
  "vm"
101
105
  ],
106
+ "repository": {
107
+ "type": "git",
108
+ "url": "git+https://github.com/a11ign/screenreader-fleet.git"
109
+ },
102
110
  "scripts": {
103
- "build": "rslib build"
111
+ "build": "rslib build",
112
+ "lint": "eslint .",
113
+ "typecheck": "tsc --noEmit",
114
+ "test": "rstest run"
104
115
  }
105
116
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Build a local NVDA capture worker VM on Apple Silicon, unattended.
3
3
  #
4
- # ./packages/worker-fleet/src/local-worker/build-vm.sh /path/to/Win11_ARM64.iso
4
+ # ./src/local-worker/build-vm.sh /path/to/Win11_ARM64.iso
5
5
  #
6
6
  # Produces a self-contained VM directory (default ~/a11y-worker-vm) holding the disk
7
7
  # image, UEFI vars and a run script. That directory IS the portable artifact: copy it
@@ -40,7 +40,7 @@ REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
40
40
  die() { echo "error: $*" >&2; exit 1; }
41
41
  info() { echo "==> $*"; }
42
42
 
43
- [ -n "$WIN_ISO" ] || die "usage: $0 <windows-11-arm64.iso> (build one with CrystalFetch, or packages/worker-fleet/src/local-worker/fetch-windows-iso.sh)"
43
+ [ -n "$WIN_ISO" ] || die "usage: $0 <windows-11-arm64.iso> (build one with CrystalFetch, or src/local-worker/fetch-windows-iso.sh)"
44
44
  [ -f "$WIN_ISO" ] || die "not found: $WIN_ISO"
45
45
  command -v qemu-system-aarch64 >/dev/null || die "qemu missing: brew install qemu"
46
46
  command -v qemu-img >/dev/null || die "qemu-img missing: brew install qemu"
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Clone the local NVDA worker VM into an additional, independent worker.
3
3
  #
4
- # ./packages/worker-fleet/src/local-worker/clone-worker.sh [new-name] # default: a11y-worker-2
4
+ # ./src/local-worker/clone-worker.sh [new-name] # default: a11y-worker-2
5
5
  #
6
6
  # One worker serves one capture at a time by design (one desktop, one foreground window, one
7
7
  # NVDA), so throughput scales by running more of them. On APFS the clone is copy-on-write, so
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Create the NVDA worker VM in UTM, fully from the CLI. No GUI clicking.
3
3
  #
4
- # ./packages/worker-fleet/src/local-worker/create-utm-vm.sh <windows-arm64.iso> [support.iso]
4
+ # ./src/local-worker/create-utm-vm.sh <windows-arm64.iso> [support.iso]
5
5
  #
6
6
  # Why UTM rather than plain QEMU: homebrew QEMU + HVF cannot boot Windows 11 ARM64 on
7
7
  # Apple Silicon (open upstream bug, https://gitlab.com/qemu-project/qemu/-/issues/2893,
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Build an official Windows 11 ARM64 ISO on macOS, from the CLI.
3
3
  #
4
- # ./packages/worker-fleet/src/local-worker/fetch-windows-iso.sh [outdir]
4
+ # ./src/local-worker/fetch-windows-iso.sh [outdir]
5
5
  #
6
6
  # Microsoft's ARM64 ISO download is a session-token web flow that does not script
7
7
  # cleanly, so this uses UUP dump: it fetches the same Unified Update Platform packages
@@ -16,7 +16,7 @@ set -euo pipefail
16
16
 
17
17
  # architecture-audit.md §8: builds an ISO for a local UTM worker VM, which is deprecated -- "The UTM is
18
18
  # deprecated, that was a testing thing." (repository owner, 2026-09-05). Bare-metal boxes install via
19
- # PXE/autounattend.xml instead — see packages/worker-fleet/src/provisioning/bare-metal/.
19
+ # PXE/autounattend.xml instead — see src/provisioning/bare-metal/.
20
20
  echo "DEPRECATED: fetch-windows-iso.sh feeds a local UTM worker VM build. UTM was a testing path and is not the fleet." >&2
21
21
  echo "Capture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy." >&2
22
22
 
@@ -100,7 +100,7 @@ cd "$OUT_DIR"
100
100
  EXISTING="$(ls -t "$OUT_DIR"/*.ISO "$OUT_DIR"/*.iso 2>/dev/null | grep -vi support | head -1 || true)"
101
101
  if [ -n "$EXISTING" ] && xorriso -indev "$EXISTING" -report_el_torito plain 2>/dev/null | grep -q UEFI; then
102
102
  echo "ISO ready (already built): $EXISTING"
103
- echo "Next: ./packages/worker-fleet/src/local-worker/create-utm-vm.sh \"$EXISTING\""
103
+ echo "Next: ./src/local-worker/create-utm-vm.sh \"$EXISTING\""
104
104
  exit 0
105
105
  fi
106
106
  if [ -n "$EXISTING" ]; then
@@ -234,5 +234,5 @@ fi
234
234
 
235
235
  echo
236
236
  echo "ISO ready: $ISO"
237
- echo "Next: ./packages/worker-fleet/src/local-worker/build-vm.sh \"$ISO\""
238
- echo " ./packages/worker-fleet/src/local-worker/create-utm-vm.sh \"$ISO\""
237
+ echo "Next: ./src/local-worker/build-vm.sh \"$ISO\""
238
+ echo " ./src/local-worker/create-utm-vm.sh \"$ISO\""
@@ -3,20 +3,20 @@
3
3
  # while you are not capturing.
4
4
  #
5
5
  # npm run worker:ctl -- up # make it ready (start or resume), wait for /health
6
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh pause # freeze it: ~0.6% CPU, instant resume, RAM not guaranteed
7
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh stop # shut it down: nothing held, ~15 s to come back
8
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh status # state, resource use, health
9
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh json # the same, machine-readable (used by the CLI)
10
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh pool # every a11y-worker* VM, as JSON
11
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh pool-up # start them all, wait for health
12
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh pool-stop # shut the whole pool down
13
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh pool-pause # freeze the whole pool
6
+ # ./src/local-worker/worker-ctl.sh pause # freeze it: ~0.6% CPU, instant resume, RAM not guaranteed
7
+ # ./src/local-worker/worker-ctl.sh stop # shut it down: nothing held, ~15 s to come back
8
+ # ./src/local-worker/worker-ctl.sh status # state, resource use, health
9
+ # ./src/local-worker/worker-ctl.sh json # the same, machine-readable (used by the CLI)
10
+ # ./src/local-worker/worker-ctl.sh pool # every a11y-worker* VM, as JSON
11
+ # ./src/local-worker/worker-ctl.sh pool-up # start them all, wait for health
12
+ # ./src/local-worker/worker-ctl.sh pool-stop # shut the whole pool down
13
+ # ./src/local-worker/worker-ctl.sh pool-pause # freeze the whole pool
14
14
  #
15
15
  # One VM serves one capture at a time, so throughput comes from more VMs. `pool` reports the
16
16
  # lot; add one with clone-worker.sh (which handles the duplicate-MAC trap).
17
17
  # Operate on a single named VM with A11Y_VM_NAME=a11y-worker-2.
18
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh idle-pause 15 # watch, then pause after 15 idle minutes
19
- # ./packages/worker-fleet/src/local-worker/worker-ctl.sh idle-stop 30 # same but shut down instead
18
+ # ./src/local-worker/worker-ctl.sh idle-pause 15 # watch, then pause after 15 idle minutes
19
+ # ./src/local-worker/worker-ctl.sh idle-stop 30 # same but shut down instead
20
20
  #
21
21
  # Measured on an M4 Max, 4 vCPU / 8 GB guest. Every number here was observed on this
22
22
  # machine; none is an estimate:
@@ -117,7 +117,7 @@ resolve_uuid() {
117
117
  [ -n "${A11Y_VM_UUID:-}" ] && { echo "$A11Y_VM_UUID"; return; }
118
118
  local matches
119
119
  matches="$(utmctl list | awk -v n="$VM_NAME" '$3 == n { print $1 }')"
120
- [ -n "$matches" ] || die "no VM named '$VM_NAME' (create one: packages/worker-fleet/src/local-worker/create-utm-vm.sh)"
120
+ [ -n "$matches" ] || die "no VM named '$VM_NAME' (create one: src/local-worker/create-utm-vm.sh)"
121
121
  if [ "$(echo "$matches" | wc -l | tr -d ' ')" -gt 1 ]; then
122
122
  # Do NOT guess, and do NOT suggest deleting one. Duplicate registrations under the same
123
123
  # name point at the SAME <name>.utm bundle, so `utmctl delete` on either removes that
@@ -317,7 +317,7 @@ case "$CMD" in
317
317
  echo " it is down for 5-10s during a restart; if it persists:" >&2
318
318
  echo " utmctl exec <uuid> --cmd powershell.exe -NoProfile -Command 'Start-ScheduledTask -TaskName a11ysrv'" >&2
319
319
  else
320
- # `$0` is this file's path, which after M6 is `packages/worker-fleet/src/local-worker/worker-ctl.sh` — true,
320
+ # `$0` is this file's path, which after M6 is `src/local-worker/worker-ctl.sh` — true,
321
321
  # and not what anyone wants to type. The npm alias is the stable way to say it, and it is what the docs use.
322
322
  echo " no guest IP either, so the VM itself is not ready. Try 'npm run worker:ctl -- up'." >&2
323
323
  fi
@@ -22,7 +22,7 @@
22
22
  # and run-server.cmd (so every worker start re-applies it for that session).
23
23
  #
24
24
  # Style note: `#` line comments and no param() block, matching the other scripts here --
25
- # see packages/worker-fleet/src/provisioning/diagnose-nvda-worker.ps1 for why.
25
+ # see src/provisioning/diagnose-nvda-worker.ps1 for why.
26
26
 
27
27
  $ErrorActionPreference = 'Stop'
28
28
 
@@ -3,7 +3,7 @@
3
3
  # Run this ONCE, in the VM, in an elevated PowerShell, right after Windows setup:
4
4
  #
5
5
  # Set-ExecutionPolicy -Scope Process Bypass -Force
6
- # irm https://raw.githubusercontent.com/a11ign/a11ign/main/packages/worker-fleet/src/provisioning/bootstrap-windows-worker.ps1 | iex
6
+ # irm https://raw.githubusercontent.com/a11ign/screenreader-fleet/main/src/provisioning/bootstrap-windows-worker.ps1 | iex
7
7
  #
8
8
  # ...or, if you already have the repo, just run this file. It installs the
9
9
  # prerequisites, makes the box reachable over SSH, clones the repo, and then hands
@@ -4,7 +4,7 @@
4
4
  # VERDICT per layer rather than raw dumps, so the first FAIL is the thing to fix.
5
5
  # Exits non-zero if any check failed. Copy it over and run it with -File:
6
6
  #
7
- # scp packages/worker-fleet/src/provisioning/diagnose-nvda-worker.ps1 user@host:C:/Users/user/
7
+ # scp src/provisioning/diagnose-nvda-worker.ps1 user@host:C:/Users/user/
8
8
  # ssh user@host "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\user\diagnose-nvda-worker.ps1"
9
9
  #
10
10
  # Do NOT pipe this to `powershell -Command -`. That mode silently truncated this
@@ -47,8 +47,8 @@ param(
47
47
  Set-StrictMode -Version Latest
48
48
  $ErrorActionPreference = 'Stop'
49
49
 
50
- # TWO OF THE FIVE PATHS ARE NOT WRITTEN HERE (ADR 0039 item 6d, #3397). Where the worker layer lives is
51
- # declared in `packages/control/layers.json`, and what its launchers reach outside it in the layer's own
50
+ # THREE OF THE FIVE PATHS ARE NOT WRITTEN HERE (ADR 0039 item 6d, #3397; this repository's own file since the flat layout, a11ign/a11ign#4216).
51
+ # Where the worker layer and this fleet layer live is declared in `packages/control/layers.json`, and what its launchers reach outside it in the layer's own
52
52
  # `src/launcher-reach.cmd`, which `run-capture-check.cmd` `call`s. Both are READ, so a path cannot change in
53
53
  # the launcher and stay behind in the stamp. A declaration that is absent or does not say THROWS: the stamp
54
54
  # must not fall back to a literal, because a literal that was right yesterday is the stamp describing less
@@ -77,7 +77,7 @@ $FOREGROUND_LOCK = Get-DeclaredReach -Name 'FLT'
77
77
  # The single definition. Explicit paths rather than a filename search, because `main.yml` is not unique
78
78
  # in this repo and a search would silently pick the wrong one.
79
79
  $ENVIRONMENT_FILES = @(
80
- 'packages/worker-fleet/src/provisioning/provision-nvda-worker.ps1'
80
+ (Get-LayerFile -Layer 'screenreader-fleet' -Relative 'src/provisioning/provision-nvda-worker.ps1')
81
81
  $RUN_SERVER
82
82
  $FOREGROUND_LOCK
83
83
  'packages/control/ansible/roles/worker/defaults/main.yml'
@@ -1,11 +0,0 @@
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;