@a11ign/screenreader-fleet 0.4.1 → 0.5.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.
@@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process";
5
5
  import { errorText } from "@a11ign/screenreader-worker/error-text";
6
6
  import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
7
7
  import { inventoryWorkerUrls, configuredWorkers, resolveWorkerPool } from "./fleet-env.mjs";
8
- import { expectedWorkerCode, remedyLines, codeDrift } from "./worker-code-check.mjs";
8
+ import { remedyLines, codeDrift, resolveExpectedWorkerCode } from "./worker-code-check.mjs";
9
9
  import { refuseUnknownFlags } from "./cli-flags.mjs";
10
10
  import { requestJson } from "./worker-http.mjs";
11
11
  refuseUnknownFlags([], {
@@ -42,9 +42,9 @@ async function versionOf(url) {
42
42
  return response.json.code ?? "absent";
43
43
  }
44
44
  async function main() {
45
- const expected = expectedWorkerCode();
45
+ const { code: expected, note } = await resolveExpectedWorkerCode();
46
46
  const { urls, source } = workerUrls();
47
- console.log(`this checkout: ${expected}`);
47
+ console.log(`this checkout: ${expected} (from ${note})`);
48
48
  if (!urls.length) {
49
49
  console.log("no worker configured, running locally, or listed in inventory.yml — nothing to compare");
50
50
  process.exit(0);
package/dist/doctor.d.mts CHANGED
@@ -31,6 +31,45 @@ export function workerControlFix(observed: string): {
31
31
  fix: string | null;
32
32
  note: string | null;
33
33
  };
34
+ /**
35
+ * IS THE FLEET KEY SITTING NEXT TO 100 MB OF PACKAGES NOBODY AUDITED? — ADR 0012, checked rather than
36
+ * asserted.
37
+ *
38
+ * Found on 2026-08-29 to be violated on BOTH machines the ADR is about: the control plane carried 56 MB
39
+ * and 121 packages beside the key, and this laptop carries 103 MB beside the same key plus the lab key.
40
+ * The document was accurate about the intent and described a system that did not exist — which is worse
41
+ * than no document, because it is read as a guarantee.
42
+ *
43
+ * Reported by `doctor` because that is the command whose whole promise is that every check names its own
44
+ * fix, and because a check nobody runs is one this repo has learned not to write.
45
+ */
46
+ /**
47
+ * IS THIS CHECKOUT MARKED AS THE PRIMARY, and does that match what it looks like?
48
+ *
49
+ * The primary-checkout guards (`pre-commit`, `post-checkout`) are OPT-IN as of #198: they fire only where
50
+ * `git config --local a11y.primaryCheckout` is `true`. That is the correct default — inferring it from
51
+ * `.git` being a directory made the hook fire on the lab, which is an ordinary clone, and broke every
52
+ * `lab:job -e ref=<branch>`.
53
+ *
54
+ * But an opt-in guard nobody can find the switch for is an OFF guard, and "unmarked" must not read the
55
+ * same as "safe". So this reports the state on every run rather than only when something is wrong — the
56
+ * `isolation` check above takes the same shape for the same reason: a debt that is reported every run is
57
+ * a known one, and a debt reported never is a forgotten one.
58
+ *
59
+ * ADVISORY, never a hard failure. `doctor` exits 0 when a RUN can proceed, and an unmarked checkout can
60
+ * run perfectly well — it is the fleet-driving machine's protection that is missing, not its capability.
61
+ * A doctor that refused READY over this would be ignored, which is how a guard gets switched off.
62
+ *
63
+ * It does not GUESS which machine deserves the mark. `doctor` runs on laptops, worktrees, the lab and CI,
64
+ * and telling four of those five to mark themselves would be the #198 defect wearing an advisory's
65
+ * clothes. It states what is true and names the command; the operator decides.
66
+ */
67
+ export function checkPrimaryCheckoutMark({ baseDir }?: {
68
+ baseDir?: string | undefined;
69
+ }): void;
70
+ export function checkControlPlaneIsolation({ baseDir }?: {
71
+ baseDir?: string | undefined;
72
+ }): void;
34
73
  /**
35
74
  * Pure: does `resolvedRealPath` (already realpath'd) live under `thisCheckoutRoot` (also realpath'd)?
36
75
  * Both must be realpath'd BEFORE calling this, never inside it -- comparing a symlinked path against a
@@ -76,6 +115,40 @@ export function checkoutRootFor(resolvedRealPath: string): string | null;
76
115
  export function tscProjectUpToDate(tsconfigPath: string, { run }?: {
77
116
  run?: (cmd: string, args: string[]) => string;
78
117
  }): boolean | null;
118
+ /**
119
+ * WHOSE dist a cross-package import actually resolves to, and is IT stale (#256) -- both computed from
120
+ * the exact SPECIFIER a real import site in this repo uses, never the bare package name. CLAUDE.md's own
121
+ * recorded lesson: "resolving @a11ign/judge does not prove @a11ign/judge/rules came from your
122
+ * tree" -- a package can export subpaths from elsewhere, so resolving the root proves nothing about a
123
+ * subpath. `@a11ign/judge/rules` is a real specifier this repo imports
124
+ * (`packages/lab/scripts/score-rules.ts` and others), not a synthetic probe.
125
+ *
126
+ * ADVISORY, never a hard failure -- same reasoning as `isolation` above: a worktree resolving to the
127
+ * primary's dist can still run every command correctly today, and a doctor that refused READY over an
128
+ * environmental fact would be ignored, which is how a guard gets switched off. It is reported every run
129
+ * so a stale answer is a known condition, not a silent one.
130
+ */
131
+ export function checkCrossPackageDist({ baseDir }?: {
132
+ baseDir?: string | undefined;
133
+ }): number | void;
134
+ /**
135
+ * Where the trained scorer's weights are, asked of `@a11ign/scorer` itself (a11ign/a11ign#3784).
136
+ *
137
+ * This was `../../scorer/models/...` from this module: the monorepo layout, which from an installed package points into
138
+ * `node_modules/@a11ign/` and answers "missing" under pnpm, where the scorer sits beside `@a11ign/judge` in the store and
139
+ * not beside this package. The scorer is a PEER of the judge, so it is resolved FROM the judge's real path, the one place
140
+ * both layouts agree it is reachable, and its own `scorerPaths()` says where its weights are ("the weights are the API").
141
+ * `from` is INJECTED so a test can stand in for either layout.
142
+ *
143
+ * @param {{ from?: string }} [options] a path or file URL to resolve `@a11ign/judge` from
144
+ * @returns {Promise<string>}
145
+ */
146
+ export function scorerWeightsFor({ from }?: {
147
+ from?: string;
148
+ }): Promise<string>;
149
+ export function checkJudge({ from }?: {
150
+ from?: string | undefined;
151
+ }): Promise<void>;
79
152
  /**
80
153
  * The agreement sentence, with WHICH FIELDS AGREED DERIVED rather than retyped — #1997.
81
154
  *
@@ -225,6 +298,15 @@ export function doctorRun(deps?: {
225
298
  err?: (line: string) => void;
226
299
  runsDir?: () => string;
227
300
  }): Promise<number>;
301
+ export function recordedChecks(): {
302
+ name: string;
303
+ id: string;
304
+ ok: boolean;
305
+ detail: string;
306
+ fix: string | null;
307
+ note?: string | null;
308
+ advisory?: boolean;
309
+ }[];
228
310
  export function addCheck(name: string, ok: boolean, detail: string, remedy?: string | null | {
229
311
  fix: string | null;
230
312
  note?: string | null;
package/dist/doctor.mjs CHANGED
@@ -84,9 +84,18 @@ const REFUSED = 2;
84
84
  const WORKERS_ENV = configuredWorkers();
85
85
  const PAGES_PORT = Number(process.env.DATASET_PAGES_PORT || 5050);
86
86
  const CTL = fleetScriptPaths().workerCtl;
87
- const SCORER_MODEL_DIR = fileURLToPath(new URL("../../scorer/models/screenreader-scorer/", import.meta.url));
88
87
  const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
89
88
  const isMonorepoRoot = (root)=>external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
89
+ function checkoutLayout(baseDir = MODULE_DIR) {
90
+ const root = resolve(baseDir, "..", "..", "..");
91
+ return {
92
+ root,
93
+ checkout: isMonorepoRoot(root)
94
+ };
95
+ }
96
+ const notACheckout = (names, root)=>{
97
+ for (const name of names)advise(name, `n/a: installed package -- ${root} is not a checkout, so there is nothing here for this check to read`);
98
+ };
90
99
  function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
91
100
  const supplied = flagValue(argv, "runs-dir");
92
101
  if (void 0 !== supplied) {
@@ -100,6 +109,7 @@ function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {})
100
109
  const datasetDir = ()=>resolve(runsDirFor(), "screenreader-dataset");
101
110
  const PROBE_TIMEOUT_MS = 8000;
102
111
  const checks = [];
112
+ const recordedChecks = ()=>checks;
103
113
  const GATES = Object.freeze({
104
114
  worker: true,
105
115
  fleet: true,
@@ -182,8 +192,11 @@ async function httpJson(url) {
182
192
  if (void 0 === response.json) throw new Error(`invalid JSON from ${url}`);
183
193
  return response.json;
184
194
  }
185
- function checkPrimaryCheckoutMark() {
186
- const root = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
195
+ function checkPrimaryCheckoutMark({ baseDir = MODULE_DIR } = {}) {
196
+ const { root, checkout } = checkoutLayout(baseDir);
197
+ if (!checkout) return notACheckout([
198
+ "primary checkout"
199
+ ], root);
187
200
  let linked;
188
201
  try {
189
202
  linked = !statSync(resolve(root, ".git")).isDirectory();
@@ -209,17 +222,19 @@ function checkPrimaryCheckoutMark() {
209
222
  if (marked) return add("primary checkout", true, "MARKED — pre-commit refuses commits here and post-checkout keeps it detached at origin/main");
210
223
  advise("primary checkout", "not marked, so the primary-checkout guards are INERT here. Correct for the lab, a worker or a colleague's clone; wrong for the machine that drives the fleet.", "pnpm run primary:mark -- --set (only on the fleet-driving checkout — see docs/primary-checkout.md)");
211
224
  }
212
- function checkControlPlaneIsolation() {
225
+ function checkControlPlaneIsolation({ baseDir = MODULE_DIR } = {}) {
226
+ const { root, checkout } = checkoutLayout(baseDir);
227
+ if (!checkout) return notACheckout([
228
+ "isolation"
229
+ ], root);
213
230
  const raw = process.env.A11Y_SSH_KEY || "~/.ssh/a11y-witness_ed25519";
214
231
  const keyPath = raw.startsWith("~/") ? resolve(homedir(), raw.slice(2)) : raw;
215
232
  const hasFleetKey = external_node_fs_existsSync(keyPath);
216
- const root = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
217
233
  const hasNodeModules = external_node_fs_existsSync(resolve(root, "node_modules"));
218
- const isWorkspace = external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
219
234
  const verdict = controlPlaneIsolation({
220
235
  hasNodeModules,
221
236
  hasFleetKey,
222
- isWorkspace
237
+ isWorkspace: checkout
223
238
  });
224
239
  if (!verdict.violated) return add("isolation", true, verdict.why);
225
240
  advise("isolation", verdict.why, "docs/control-plane-plan.md L3 — drive the control plane rather than holding its keys");
@@ -270,9 +285,13 @@ function behindOriginMainNote(otherRoot) {
270
285
  return "";
271
286
  }
272
287
  }
273
- function checkCrossPackageDist() {
288
+ function checkCrossPackageDist({ baseDir = MODULE_DIR } = {}) {
274
289
  const specifier = "@a11ign/judge/rules";
275
- const thisCheckoutRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..");
290
+ const { root: thisCheckoutRoot, checkout } = checkoutLayout(baseDir);
291
+ if (!checkout) return notACheckout([
292
+ "dist-resolution",
293
+ "dist-freshness"
294
+ ], thisCheckoutRoot);
276
295
  let resolvedRealPath;
277
296
  try {
278
297
  resolvedRealPath = external_node_fs_realpathSync(createRequire(import.meta.url).resolve(specifier));
@@ -291,11 +310,24 @@ function checkCrossPackageDist() {
291
310
  if (upToDate) return add("dist-freshness", true, `packages/judge under ${distRoot} is up to date (tsc --build --dry)`);
292
311
  advise("dist-freshness", `packages/judge under ${distRoot} is NOT up to date (tsc --build --dry) -- a build compiled before the source it now reflects`, "pnpm run build # in that checkout");
293
312
  }
294
- async function checkJudge() {
313
+ async function scorerWeightsFor({ from = import.meta.url } = {}) {
314
+ const judgeEntry = external_node_fs_realpathSync(createRequire(from).resolve("@a11ign/judge"));
315
+ const scorerEntry = external_node_fs_realpathSync(createRequire(judgeEntry).resolve("@a11ign/scorer"));
316
+ const { scorerPaths } = await import(pathToFileURL(scorerEntry).href);
317
+ return scorerPaths().weights;
318
+ }
319
+ async function checkJudge({ from = import.meta.url } = {}) {
295
320
  const backend = (process.env.JUDGE_BACKEND || "local").toLowerCase();
296
321
  if ("local" === backend) {
297
- const weights = resolve(SCORER_MODEL_DIR, "model.safetensors");
298
- return add("judge", external_node_fs_existsSync(weights), external_node_fs_existsSync(weights) ? "backend=local, trained scorer present" : "backend=local, but the trained scorer is missing", `expected weights at ${weights} — they ship in the repo, so this means an incomplete checkout`);
322
+ let weights;
323
+ try {
324
+ weights = await scorerWeightsFor({
325
+ from
326
+ });
327
+ } catch (error) {
328
+ return add("judge", false, `backend=local, but @a11ign/scorer could not be resolved from @a11ign/judge: ${error.message}`, "install @a11ign/scorer (a peer dependency of @a11ign/judge) beside @a11ign/judge");
329
+ }
330
+ return add("judge", external_node_fs_existsSync(weights), external_node_fs_existsSync(weights) ? "backend=local, trained scorer present" : "backend=local, but the trained scorer is missing", `expected weights at ${weights} (where @a11ign/scorer says they are) — reinstall @a11ign/scorer`);
299
331
  }
300
332
  if ("anthropic" === backend || "openai" === backend) {
301
333
  const key = "anthropic" === backend ? "ANTHROPIC_API_KEY" : "JUDGE_BASE_URL";
@@ -532,4 +564,4 @@ async function main() {
532
564
  process.exit(await doctorRun());
533
565
  }
534
566
  if (import.meta.url === pathToFileURL(process.argv[1] ? external_node_fs_realpathSync(process.argv[1]) : "").href) await main();
535
- export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, runsDirFor, tscProjectUpToDate, workerControlFix };
567
+ export { addCheck, allChecks, checkControlPlaneIsolation, checkCrossPackageDist, checkJudge, checkPrimaryCheckoutMark, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, recordedChecks, resolvesToThisCheckout, runsDirFor, scorerWeightsFor, tscProjectUpToDate, workerControlFix };
@@ -1,3 +1,29 @@
1
+ /**
2
+ * The hash every worker is expected to be serving, and WHERE IT CAME FROM. ONE function, asked by `a11ign-worker-code` and by
3
+ * `assertFleetRunsThisCheckout` alike, so the two cannot be given different hashers again (a11ign/a11ign#3781).
4
+ *
5
+ * 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
6
+ * `layerCodeVersion("nvda-worker")` computes for the deploy and the lab, and what a guest is told to be on (`--layer-ref`). Asking the
7
+ * installed package instead made every worker read stale the first time a guest was deployed at a sha whose `.mjs` differed from the
8
+ * release, with a remedy ("redeploy") that could not clear it.
9
+ *
10
+ * With none it is the installed package's (a11ign/a11ign#3740: the one the package was RELEASED with, `codeVersion()` and no
11
+ * directory, because the built package's `workerSourceDir()` is `dist/` and holds none of the files a guest runs), and `source` and
12
+ * `note` say so, so a reading is never silent about which it was.
13
+ *
14
+ * ASYNC because the clone's hasher can only be imported dynamically, as `layerCodeVersion` does.
15
+ *
16
+ * @param {{ checkoutRoot?: string }} [options]
17
+ * @returns {Promise<{ code: string, source: "clone" | "installed", sourceDir: string, note: string }>}
18
+ */
19
+ export function resolveExpectedWorkerCode({ checkoutRoot }?: {
20
+ checkoutRoot?: string;
21
+ }): Promise<{
22
+ code: string;
23
+ source: "clone" | "installed";
24
+ sourceDir: string;
25
+ note: string;
26
+ }>;
1
27
  /**
2
28
  * Refuse to capture with a fleet that is not running this checkout.
3
29
  *
@@ -10,15 +36,15 @@
10
36
  * A thin wrapper over `assertWorkersServe`, supplying the one thing only this file can compute: the hash.
11
37
  *
12
38
  * @param {string[]} workers
13
- * @param {{when?: string, allow?: boolean, read?: (url: string) => Promise<string|null>, bareMetalUrls?: string[]}} options
39
+ * @param {{when?: string, allow?: boolean, read?: (url: string) => Promise<string|null>, bareMetalUrls?: string[], checkoutRoot?: string}} options
14
40
  */
15
41
  export function assertFleetRunsThisCheckout(workers: string[], options?: {
16
42
  when?: string;
17
43
  allow?: boolean;
18
44
  read?: (url: string) => Promise<string | null>;
19
45
  bareMetalUrls?: string[];
46
+ checkoutRoot?: string;
20
47
  }): Promise<void>;
21
- export function expectedWorkerCode(): string;
22
48
  import { codeDrift } from "./code-drift.mjs";
23
49
  import { describeCodeDrift } from "./code-drift.mjs";
24
50
  import { describeEmptyPool } from "./code-drift.mjs";
@@ -1,5 +1,8 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { codeVersion, workerSourceDir } from "@a11ign/screenreader-worker/code-version";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
3
6
  import { requestJson } from "./worker-http.mjs";
4
7
  import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
5
8
  const HEALTH_TIMEOUT_MS = 15000;
@@ -119,11 +122,49 @@ async function assertWorkersServe(expected, workers, options) {
119
122
  process.stderr.write(refusal);
120
123
  process.exit(3);
121
124
  }
122
- const expectedWorkerCode = ()=>codeVersion();
125
+ const NVDA_WORKER_LAYER = "nvda-worker";
126
+ function layerClone(checkoutRoot) {
127
+ const manifestPath = join(checkoutRoot, "packages", "control", "layers.json");
128
+ if (!existsSync(manifestPath)) return {
129
+ absent: `no packages/control/layers.json under ${checkoutRoot}`
130
+ };
131
+ const layer = JSON.parse(readFileSync(manifestPath, "utf8")).layers?.[NVDA_WORKER_LAYER];
132
+ if (!layer?.path) return {
133
+ absent: `${manifestPath} does not declare "${NVDA_WORKER_LAYER}"`
134
+ };
135
+ const dir = resolve(checkoutRoot, layer.path);
136
+ return existsSync(dir) ? {
137
+ dir
138
+ } : {
139
+ absent: `no layer clone at ${dir}`
140
+ };
141
+ }
142
+ async function resolveExpectedWorkerCode({ checkoutRoot = process.cwd() } = {}) {
143
+ const clone = layerClone(checkoutRoot);
144
+ if ("absent" in clone) return {
145
+ code: codeVersion(),
146
+ source: "installed",
147
+ sourceDir: workerSourceDir(),
148
+ note: `the installed @a11ign/screenreader-worker (${clone.absent})`
149
+ };
150
+ const sourceDir = `${join(clone.dir, "src")}/`;
151
+ const hasher = await import(pathToFileURL(join(sourceDir, "code-version.mjs")).href);
152
+ return {
153
+ code: hasher.codeVersion(sourceDir),
154
+ source: "clone",
155
+ sourceDir,
156
+ note: `the layer clone at ${clone.dir}`
157
+ };
158
+ }
123
159
  async function assertFleetRunsThisCheckout(workers, options = {}) {
124
- return assertWorkersServe(expectedWorkerCode(), workers, {
125
- ...options,
126
- sourceDir: workerSourceDir()
160
+ const { checkoutRoot, ...rest } = options;
161
+ const expected = await resolveExpectedWorkerCode({
162
+ checkoutRoot
163
+ });
164
+ if (!options.allow) process.stdout.write(`Expected worker code ${expected.code}, from ${expected.note}.\n`);
165
+ return assertWorkersServe(expected.code, workers, {
166
+ ...rest,
167
+ sourceDir: expected.sourceDir
127
168
  });
128
169
  }
129
- export { assertFleetRunsThisCheckout, codeDrift, describeCodeDrift, describeEmptyPool, expectedWorkerCode, readWorkerCode, remedyLines, workerSourceDirty };
170
+ export { assertFleetRunsThisCheckout, codeDrift, describeCodeDrift, describeEmptyPool, readWorkerCode, remedyLines, resolveExpectedWorkerCode, workerSourceDirty };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11ign/screenreader-fleet",
3
- "version": "0.4.1",
3
+ "version": "0.5.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",