@a11ign/screenreader-fleet 0.3.0 → 0.4.1

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.
@@ -31,9 +31,10 @@ export function codeDrift(expected: string, readings: Array<{
31
31
  /**
32
32
  * Name the deploy route that can actually reach these workers.
33
33
  *
34
- * There are two, they share no mechanism, and the wrong one wastes real time. `worker:deploy` is
35
- * `utmctl file push` plus a `utmctl` reboot: it takes a VM UUID and fails immediately off macOS, so it
36
- * cannot touch a physical box. Bare-metal workers are git-cloned and deploy by PULLING, through Ansible.
34
+ * Only bare-metal workers have one: they are git-cloned and deploy by PULLING, through Ansible. The UTM
35
+ * route (`a11ign-worker-deploy`, `utmctl file push` plus a `utmctl` reboot, keyed on a VM UUID) is gone: it
36
+ * pushed from the worker package's `src`, which a built install does not have (#3765), and no fleet box
37
+ * was ever reachable by it.
37
38
  *
38
39
  * This printed the utmctl advice unconditionally, including to a fleet of four mini PCs where none of it
39
40
  * applies — a tool confidently prescribing a remedy for a different kind of machine. Which kind a worker is
@@ -1,2 +1,18 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /**
3
+ * Where the report is written: under the caller's `--runs-dir=`, else the monorepo's own `runs/`, else a refusal naming
4
+ * the directory and the flag (a11ign/a11ign#3767).
5
+ *
6
+ * Resolved from THIS module, never the cwd -- same reason as `doctor.mjs`'s `runsDirFor`, which this duplicates because
7
+ * this package cannot import `@a11ign/lab`'s canonical `runs/` resolution without a dependency cycle. From an INSTALLED
8
+ * package `../../../` is `node_modules`, so the default would write the report under the dependency. A supplied
9
+ * directory need not exist: the report's own `mkdirSync` makes it, which is what asking for one means.
10
+ * `baseDir` is INJECTED so a test can stand in for either layout.
11
+ *
12
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
13
+ * @returns {string}
14
+ */
15
+ export function outDirFor({ argv, baseDir }?: {
16
+ argv?: readonly string[];
17
+ baseDir?: string;
18
+ }): string;
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath, pathToFileURL } from "node:url";
4
4
  import { execFileSync } from "node:child_process";
5
5
  import { resolve } from "node:path";
6
6
  import { describeProbe, probeHealth, WORKER_PROBE_TIMEOUT_MS } from "./probe-outcome.mjs";
7
7
  import { CAPTURE_CLIENT_TIMEOUT_MS, requestJson } from "./worker-http.mjs";
8
- import { refuseUnknownFlags } from "./cli-flags.mjs";
8
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
9
9
  async function refuseIfBusy(workers, { what, timeoutMs = WORKER_PROBE_TIMEOUT_MS, request }) {
10
10
  const states = await Promise.all(workers.map(async (worker)=>{
11
11
  const probe = await probeHealth(worker, {
@@ -221,12 +221,22 @@ function diffHost(before, after) {
221
221
  }
222
222
  refuseUnknownFlags([
223
223
  "--rounds=",
224
- "--runs="
224
+ "--runs=",
225
+ "--runs-dir="
225
226
  ], {
226
227
  entry: import.meta.url,
227
228
  command: "npm run worker:compare"
228
229
  });
229
- const OUT = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/worker-compare");
230
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
231
+ const isMonorepoRoot = (root)=>existsSync(resolve(root, "packages")) && existsSync(resolve(root, "package.json"));
232
+ function outDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
233
+ const supplied = flagValue(argv, "runs-dir");
234
+ if ("" === supplied) throw new Error("worker:compare: --runs-dir= is empty. Pass --runs-dir=<the runs directory>.");
235
+ if (void 0 !== supplied) return resolve(supplied, "worker-compare");
236
+ const root = resolve(baseDir, "..", "..", "..");
237
+ if (isMonorepoRoot(root)) return resolve(root, "runs", "worker-compare");
238
+ throw new Error(`worker:compare: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to write worker-compare/ under. Pass --runs-dir=<the runs directory>.`);
239
+ }
230
240
  const MS_PER_S = 1000;
231
241
  function refuseIfRunsReadonly() {
232
242
  if ("1" !== process.env.A11Y_RUNS_READONLY) return;
@@ -234,6 +244,14 @@ function refuseIfRunsReadonly() {
234
244
  console.error(" Unset A11Y_RUNS_READONLY to run this for real.");
235
245
  process.exit(3);
236
246
  }
247
+ function refuseUnlessOutDir() {
248
+ try {
249
+ return outDirFor();
250
+ } catch (error) {
251
+ process.stderr.write(`${error.message}\n`);
252
+ return process.exit(2);
253
+ }
254
+ }
237
255
  const compare_workers_args = process.argv.slice(2);
238
256
  const runs = Number(compare_workers_args.find((a)=>a.startsWith("--rounds="))?.slice(9) ?? compare_workers_args.find((a)=>a.startsWith("--runs="))?.slice(7) ?? 6);
239
257
  const [page, ...compare_workers_workers] = compare_workers_args.filter((a)=>!a.startsWith("--"));
@@ -297,6 +315,7 @@ async function main() {
297
315
  process.stderr.write("usage: npm run worker:compare -- <page-url> <worker> <worker> [--rounds=6]\n");
298
316
  process.exit(2);
299
317
  }
318
+ const outDir = refuseUnlessOutDir();
300
319
  refuseIfRunsReadonly();
301
320
  await refuseIfBusy(compare_workers_workers, {
302
321
  what: `worker:compare against ${page}`
@@ -334,10 +353,11 @@ async function main() {
334
353
  results,
335
354
  vitalsBefore,
336
355
  vitalsAfter,
337
- hostBefore
356
+ hostBefore,
357
+ outDir
338
358
  });
339
359
  }
340
- function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
360
+ function report({ results, vitalsBefore, vitalsAfter, hostBefore, outDir }) {
341
361
  const allPhases = [
342
362
  ...new Set(Object.values(results).flatMap((r)=>r.phases.flatMap(Object.keys)))
343
363
  ];
@@ -355,10 +375,10 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
355
375
  const { foundations, hostAfter } = reportFoundations({
356
376
  hostBefore
357
377
  });
358
- mkdirSync(OUT, {
378
+ mkdirSync(outDir, {
359
379
  recursive: true
360
380
  });
361
- writeFileSync(resolve(OUT, "compare.json"), JSON.stringify({
381
+ writeFileSync(resolve(outDir, "compare.json"), JSON.stringify({
362
382
  page: page,
363
383
  rounds: runs,
364
384
  results,
@@ -368,7 +388,7 @@ function report({ results, vitalsBefore, vitalsAfter, hostBefore }) {
368
388
  hostBefore,
369
389
  hostAfter
370
390
  }, null, 2) + "\n", "utf8");
371
- process.stdout.write(`\nReport: ${resolve(OUT, "compare.json")}\n`);
391
+ process.stdout.write(`\nReport: ${resolve(outDir, "compare.json")}\n`);
372
392
  }
373
393
  function reportWallTime({ results, vitalsBefore, vitalsAfter }) {
374
394
  const wallSeconds = Object.fromEntries(compare_workers_workers.map((w)=>[
@@ -458,3 +478,4 @@ function reportFoundations({ hostBefore }) {
458
478
  };
459
479
  }
460
480
  if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
481
+ export { outDirFor };
package/dist/doctor.d.mts CHANGED
@@ -1,4 +1,21 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * The `runs/` directory doctor reads the dataset from: the caller's `--runs-dir=`, else the monorepo's own `runs/`,
4
+ * else a refusal naming the directory and the flag (a11ign/a11ign#3767).
5
+ *
6
+ * `@a11ign/lab` owns the canonical `runs/` resolution (`packages/lab/src/dataset-paths.mjs`), but `lab` depends on
7
+ * `worker-fleet`, so this package cannot import it without a cycle: the same computation, duplicated (and again in
8
+ * `compare-workers.mjs`) rather than left cwd-anchored. From an INSTALLED package `../../../` is `node_modules`, so the
9
+ * default is taken only when that directory is a checkout; a missing `runs/` inside one is normal (a fresh clone).
10
+ * `baseDir` is INJECTED so a test can stand in for either layout.
11
+ *
12
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
13
+ * @returns {string}
14
+ */
15
+ export function runsDirFor({ argv, baseDir }?: {
16
+ argv?: readonly string[];
17
+ baseDir?: string;
18
+ }): string;
2
19
  /**
3
20
  * The remedy for a failed local-pool query, as a runnable command plus the advice that is not one.
4
21
  *
@@ -198,7 +215,7 @@ export function errorDocument(error: unknown): {
198
215
  * The whole run, as a function of its steps and its streams, returning an exit code.
199
216
  *
200
217
  * @param {{ steps?: (() => unknown)[], json?: boolean, out?: (line: string) => void,
201
- * err?: (line: string) => void }} [deps]
218
+ * err?: (line: string) => void, runsDir?: () => string }} [deps]
202
219
  * @returns {Promise<number>}
203
220
  */
204
221
  export function doctorRun(deps?: {
@@ -206,6 +223,7 @@ export function doctorRun(deps?: {
206
223
  json?: boolean;
207
224
  out?: (line: string) => void;
208
225
  err?: (line: string) => void;
226
+ runsDir?: () => string;
209
227
  }): Promise<number>;
210
228
  export function addCheck(name: string, ok: boolean, detail: string, remedy?: string | null | {
211
229
  fix: string | null;
package/dist/doctor.mjs CHANGED
@@ -10,7 +10,7 @@ import { availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
10
10
  import { fleetConsistency, describeMismatches } from "./fleet-consistency.mjs";
11
11
  import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
12
12
  import { namedInventoryWorkers, configuredWorkers } from "./fleet-env.mjs";
13
- import { refuseUnknownFlags } from "./cli-flags.mjs";
13
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
14
14
  import { requestJson } from "./worker-http.mjs";
15
15
  import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
16
16
  import { assessWorker } from "./worker-health.mjs";
@@ -72,18 +72,32 @@ function shimInvocation(shim, script, args) {
72
72
  };
73
73
  }
74
74
  refuseUnknownFlags([
75
- "--json"
75
+ "--json",
76
+ "--runs-dir="
76
77
  ], {
77
78
  entry: import.meta.url,
78
79
  command: "pnpm run doctor"
79
80
  });
80
81
  const doctor_run = promisify(execFile);
81
82
  const JSON_OUT = process.argv.includes("--json");
83
+ const REFUSED = 2;
82
84
  const WORKERS_ENV = configuredWorkers();
83
85
  const PAGES_PORT = Number(process.env.DATASET_PAGES_PORT || 5050);
84
86
  const CTL = fleetScriptPaths().workerCtl;
85
87
  const SCORER_MODEL_DIR = fileURLToPath(new URL("../../scorer/models/screenreader-scorer/", import.meta.url));
86
- const DATASET = resolve(fileURLToPath(new URL("../../../", import.meta.url)), "runs/screenreader-dataset");
88
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
89
+ const isMonorepoRoot = (root)=>external_node_fs_existsSync(resolve(root, "packages")) && external_node_fs_existsSync(resolve(root, "package.json"));
90
+ function runsDirFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
91
+ const supplied = flagValue(argv, "runs-dir");
92
+ if (void 0 !== supplied) {
93
+ if (external_node_fs_existsSync(supplied)) return resolve(supplied);
94
+ throw new Error(`doctor: --runs-dir=${supplied} does not exist. Pass --runs-dir=<the runs directory>.`);
95
+ }
96
+ const root = resolve(baseDir, "..", "..", "..");
97
+ if (isMonorepoRoot(root)) return resolve(root, "runs");
98
+ throw new Error(`doctor: no runs directory: ${root} is not a checkout (this is an installed package), so there is no runs/ to default to. Pass --runs-dir=<the runs directory>.`);
99
+ }
100
+ const datasetDir = ()=>resolve(runsDirFor(), "screenreader-dataset");
87
101
  const PROBE_TIMEOUT_MS = 8000;
88
102
  const checks = [];
89
103
  const GATES = Object.freeze({
@@ -419,7 +433,7 @@ function checkHostCapacity(pool) {
419
433
  add("host memory", true, limit >= poolSize ? detail : `${detail}; the rest stay stopped so the run does not swap (override: A11Y_MAX_WORKERS)`);
420
434
  }
421
435
  async function checkDatasetPages() {
422
- const manifestPath = resolve(DATASET, "manifest.json");
436
+ const manifestPath = resolve(datasetDir(), "manifest.json");
423
437
  if (!external_node_fs_existsSync(manifestPath)) return add("dataset", false, "no manifest — the dataset has not been generated", "pnpm run training:generate");
424
438
  const sample = JSON.parse(readFileSync(manifestPath, "utf8")).cases?.[0]?.id;
425
439
  const probe = sample ? `${sample}/good.html` : "";
@@ -434,7 +448,7 @@ async function checkDatasetPages() {
434
448
  }
435
449
  }
436
450
  function checkRunState() {
437
- const progress = resolve(DATASET, "capture-progress.json");
451
+ const progress = resolve(datasetDir(), "capture-progress.json");
438
452
  if (!external_node_fs_existsSync(progress)) return add("run", true, "no capture run recorded");
439
453
  const p = JSON.parse(readFileSync(progress, "utf8"));
440
454
  if (!p.startedAt) return add("run", true, "no capture run recorded");
@@ -475,7 +489,14 @@ function errorDocument(error) {
475
489
  };
476
490
  }
477
491
  async function doctorRun(deps = {}) {
478
- const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error } = deps;
492
+ const { steps = DEFAULT_STEPS, json = JSON_OUT, out = console.log, err = console.error, runsDir = runsDirFor } = deps;
493
+ try {
494
+ runsDir();
495
+ } catch (error) {
496
+ if (json) out(JSON.stringify(errorDocument(error), null, 2));
497
+ else err(error.message);
498
+ return REFUSED;
499
+ }
479
500
  try {
480
501
  for (const step of steps)await step();
481
502
  } catch (error) {
@@ -511,4 +532,4 @@ async function main() {
511
532
  process.exit(await doctorRun());
512
533
  }
513
534
  if (import.meta.url === pathToFileURL(process.argv[1] ? external_node_fs_realpathSync(process.argv[1]) : "").href) await main();
514
- export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, tscProjectUpToDate, workerControlFix };
535
+ export { addCheck, allChecks, checkoutRootFor, doctorRun, errorDocument, fleetAgreementLine, gatingChecks, isRunnableCommand, nextCommand, readyFrom, resolvesToThisCheckout, runsDirFor, tscProjectUpToDate, workerControlFix };
@@ -20,6 +20,27 @@ export function configuredWorkers(): Array<{
20
20
  name: string;
21
21
  url: string;
22
22
  }>;
23
+ /**
24
+ * The two files the COMMAND reads, from the caller or else from the monorepo layout, and a refusal naming the path
25
+ * and the flag when the one it would read is not there (a11ign/a11ign#3767).
26
+ *
27
+ * `INVENTORY` above points into `packages/control`, which an installed `@a11ign/screenreader-fleet` does not have, so
28
+ * `fleet-env --list` there died on an ENOENT for a path nobody could have guessed. The library functions keep their
29
+ * default because their answer for "no inventory here" is `[]`, which is supported; a command that prints
30
+ * `export A11Y_WORKERS=''` for a missing file would be the same silent wrong answer, so it refuses.
31
+ *
32
+ * `baseDir` is INJECTED so a test can stand in for either layout without moving a file.
33
+ *
34
+ * @param {{ argv?: readonly string[], baseDir?: string }} [options]
35
+ * @returns {{ inventoryPath: string, groupVarsPath: string }}
36
+ */
37
+ export function inventoryPathsFor({ argv, baseDir }?: {
38
+ argv?: readonly string[];
39
+ baseDir?: string;
40
+ }): {
41
+ inventoryPath: string;
42
+ groupVarsPath: string;
43
+ };
23
44
  /**
24
45
  * Which group each line of the inventory sits in, index-aligned with `text.split(/\r?\n/)`.
25
46
  *
@@ -90,7 +111,7 @@ export function portFromGroupVars(text: string): number;
90
111
  *
91
112
  * Exists so a caller can tell a physical box from a local UTM VM, which decides how it is deployed to and
92
113
  * therefore what remedy to print. `worker:code` used to tell every stale worker to run `utmctl` and
93
- * `npm run worker:deploy`, which CANNOT reach a bare-metal box — it is a `utmctl file push` keyed on a VM
114
+ * `npm run worker:deploy` (since removed, #3765), which CANNOT reach a bare-metal box — it was a `utmctl file push` keyed on a VM
94
115
  * UUID and fails immediately off macOS. Following that advice on this fleet wastes the time it takes to
95
116
  * discover the tool was describing a different kind of machine.
96
117
  *
@@ -1,9 +1,12 @@
1
- import { readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
2
3
  import { fileURLToPath, pathToFileURL } from "node:url";
3
4
  import { assertWorkerUrl } from "./worker-http.mjs";
4
- import { refuseUnknownFlags } from "./cli-flags.mjs";
5
+ import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
5
6
  refuseUnknownFlags([
6
- "--list"
7
+ "--list",
8
+ "--inventory=",
9
+ "--group-vars="
7
10
  ], {
8
11
  entry: import.meta.url,
9
12
  command: "npm run fleet:env"
@@ -19,8 +22,32 @@ function configuredWorkers() {
19
22
  url
20
23
  }));
21
24
  }
22
- const INVENTORY = fileURLToPath(new URL("../../control/ansible/inventory.yml", import.meta.url));
23
- const GROUP_VARS = fileURLToPath(new URL("../../control/ansible/group_vars/a11y_workers.yml", import.meta.url));
25
+ const MODULE_DIR = fileURLToPath(new URL(".", import.meta.url));
26
+ const monorepoAnsibleFile = (relative, baseDir = MODULE_DIR)=>resolve(baseDir, "..", "..", "control", "ansible", relative);
27
+ const INVENTORY = monorepoAnsibleFile("inventory.yml");
28
+ const GROUP_VARS = monorepoAnsibleFile("group_vars/a11y_workers.yml");
29
+ function inventoryPathsFor({ argv = process.argv.slice(2), baseDir = MODULE_DIR } = {}) {
30
+ const inventoryPath = existingPath({
31
+ flag: "inventory",
32
+ supplied: flagValue(argv, "inventory"),
33
+ fallback: monorepoAnsibleFile("inventory.yml", baseDir)
34
+ });
35
+ const groupVarsPath = existingPath({
36
+ flag: "group-vars",
37
+ supplied: flagValue(argv, "group-vars"),
38
+ fallback: monorepoAnsibleFile("group_vars/a11y_workers.yml", baseDir)
39
+ });
40
+ return {
41
+ inventoryPath,
42
+ groupVarsPath
43
+ };
44
+ }
45
+ function existingPath({ flag, supplied, fallback }) {
46
+ const path = supplied ?? fallback;
47
+ if (existsSync(path)) return path;
48
+ const origin = void 0 === supplied ? "the monorepo layout this command defaults to has no such file" : "no such file";
49
+ throw new Error(`fleet:env: ${path} does not exist (${origin}). Pass --${flag}=<file>.`);
50
+ }
24
51
  const HOST_LINE = /^\s*ansible_host\s*:\s*(\S+)\s*$/;
25
52
  const SUSPECT = /ansible_host\s*:/;
26
53
  const KEY_LINE = /^(\s*)([A-Za-z_][\w.-]*)\s*:/;
@@ -207,8 +234,16 @@ function fleetEnvOutput(text, { port = DEFAULT_WORKER_PORT, mode = "env" } = {})
207
234
  };
208
235
  }
209
236
  function main() {
210
- const port = portFromGroupVars(readFileSync(GROUP_VARS, "utf8"));
211
- const { stdout, stderr } = fleetEnvOutput(readFileSync(INVENTORY, "utf8"), {
237
+ let paths;
238
+ try {
239
+ paths = inventoryPathsFor();
240
+ } catch (error) {
241
+ process.stderr.write(`${error.message}\n`);
242
+ process.exitCode = 2;
243
+ return;
244
+ }
245
+ const port = portFromGroupVars(readFileSync(paths.groupVarsPath, "utf8"));
246
+ const { stdout, stderr } = fleetEnvOutput(readFileSync(paths.inventoryPath, "utf8"), {
212
247
  port,
213
248
  mode: process.argv.includes("--list") ? "list" : "env"
214
249
  });
@@ -216,4 +251,4 @@ function main() {
216
251
  process.stdout.write(stdout);
217
252
  }
218
253
  if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) main();
219
- export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
254
+ export { DEFAULT_WORKER_PORT, WORKER_GROUP, configuredWorkers, fleetEnvOutput, groupPerLine, hostsOutOfCaptureSet, inventoryPathsFor, inventoryWorkerUrls, namedInventoryWorkers, portFromGroupVars, resolveWorkerPool, workerNamesFromInventory, workersFromInventory };
package/dist/index.mjs CHANGED
@@ -4,7 +4,9 @@ import { networkInterfaces } from "node:os";
4
4
  import { capacityReason, availableHostMemoryMb, workersHostCanRun } from "./host-capacity.mjs";
5
5
  import { fleetScriptPaths as fleet_scripts_fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
6
6
  import { inventoryWorkerUrls } from "./fleet-env.mjs";
7
- import { warnUtmDeprecated } from "./src_utm-deprecated_mjs.mjs";
7
+ function warnUtmDeprecated(what) {
8
+ process.stderr.write(`DEPRECATED: ${what} manages a local UTM worker VM. UTM was a testing path and is not the fleet.\nCapture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy. See CLAUDE.md's\n"Working on a Mac" section.\n`);
9
+ }
8
10
  const execFileAsync = promisify(execFile);
9
11
  const DEFAULT_WORKER = "http://localhost:8765";
10
12
  const CTL = fleet_scripts_fleetScriptPaths().workerCtl;
@@ -33,8 +33,8 @@ function remedyLines(staleUrls, bareMetalUrls) {
33
33
  const lines = [
34
34
  `\n${staleUrls.length} stale worker(s).`
35
35
  ];
36
- if (physical.length) lines.push(`\n ${physical.length} in inventory.yml — bare metal, so they deploy by PULLING:`, " npm run fleet:deploy", " `npm run worker:deploy` cannot reach these: it is utmctl, keyed on a VM UUID.");
37
- if (vms.length) lines.push(`\n ${vms.length} not in inventory.yml — local VM(s). A restart via \`utmctl exec\``, " silently does nothing on some guests; rebooting always picks up a pushed file:", " npm run worker:deploy");
36
+ if (physical.length) lines.push(`\n ${physical.length} in inventory.yml — bare metal, so they deploy by PULLING:`, " npm run fleet:deploy", " The UTM push (`a11ign-worker-deploy`) was removed and never reached these.");
37
+ if (vms.length) lines.push(`\n ${vms.length} not in inventory.yml — local VM(s). No command deploys to them any more`, " (`a11ign-worker-deploy` was removed, #3765): re-provision the VM, or declare it in inventory.yml", " so `npm run fleet:deploy` reaches it.");
38
38
  return lines;
39
39
  }
40
40
  function describeCodeDrift(drift, { when = "before the run", bareMetalUrls = [], sourceDirty = "" } = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a11ign/screenreader-fleet",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
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",
@@ -58,8 +58,7 @@
58
58
  "a11ign-doctor": "./dist/doctor.mjs",
59
59
  "a11ign-worker-code": "./dist/check-worker-code.mjs",
60
60
  "a11ign-worker-compare": "./dist/compare-workers.mjs",
61
- "a11ign-worker-ctl": "./src/local-worker/worker-ctl.sh",
62
- "a11ign-worker-deploy": "./dist/deploy-worker.mjs"
61
+ "a11ign-worker-ctl": "./src/local-worker/worker-ctl.sh"
63
62
  },
64
63
  "files": [
65
64
  "dist",
@@ -66,7 +66,7 @@ PORT="${A11Y_PORT:-8765}"
66
66
 
67
67
  # Accept `--vm=<name>` as well as A11Y_VM_NAME, and accept it in ANY position.
68
68
  #
69
- # `worker:deploy` has always taken `--vm=`, so anyone who has used that reaches for it here too — and this
69
+ # `worker:deploy` (since removed, #3765) always took `--vm=`, so anyone who used that reaches for it here too — and this
70
70
  # script silently ignored it, then reported a DIFFERENT VM's state under the name you asked for. Silently,
71
71
  # because a stray argument was simply never read. Two tools in one fleet disagreeing about how to name a
72
72
  # machine is the kind of paper cut that gets diagnosed as "the guest is broken".
@@ -589,7 +589,7 @@ if (-not $handedOff) {
589
589
  # - `workerCode` is recorded on every capture so you know what produced it. A worker that
590
590
  # silently updates itself on reboot can span two code versions inside one corpus run, and the
591
591
  # provenance stops meaning anything.
592
- # - `worker:deploy` exists and VERIFIES over /health.code, which shares no failure mode with the
592
+ # - `fleet:deploy` VERIFIES over /health.code, which shares no failure mode with the
593
593
  # push. An unattended self-update has no such check.
594
594
  # - one bad push would then brick every box in the fleet at its next restart, simultaneously.
595
595
  if (Get-ScheduledTask -TaskName 'a11ybootstrap' -ErrorAction SilentlyContinue) {
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
@@ -1,192 +0,0 @@
1
- #!/usr/bin/env node
2
- import { pathToFileURL } from "node:url";
3
- import { execFile, execFileSync } from "node:child_process";
4
- import { createReadStream, realpathSync } from "node:fs";
5
- import { promisify } from "node:util";
6
- import { resolve as external_node_path_resolve } from "node:path";
7
- import { WORKER_FILES } from "@a11ign/screenreader-worker/worker-files";
8
- import { codeVersion, workerSourceDir } from "@a11ign/screenreader-worker/code-version";
9
- import { CAPTURE_PROTOCOL_VERSION } from "@a11ign/screenreader-worker/protocol-version";
10
- import { requestJson as worker_http_requestJson } from "./worker-http.mjs";
11
- import { fleetScriptPaths } from "./src_fleet-scripts_mjs.mjs";
12
- import { refuseUnknownFlags, flagValue } from "./cli-flags.mjs";
13
- import { sandboxGitEnv } from "./src_git-safe-env_mjs.mjs";
14
- import { warnUtmDeprecated } from "./src_utm-deprecated_mjs.mjs";
15
- const MANIFEST_CASES = 1715;
16
- const CAPTURES_PER_CASE = 2;
17
- const RECAPTURE_COST = `${(MANIFEST_CASES * CAPTURES_PER_CASE).toLocaleString("en-US")} captures (${MANIFEST_CASES.toLocaleString("en-US")} cases in manifest.json x ${CAPTURES_PER_CASE}, read 2026-09-23T14:26Z; how long that takes depends on the fleet, so time a run rather than trust a figure)`;
18
- refuseUnknownFlags([
19
- "--vm=",
20
- "--allow-protocol-change"
21
- ], {
22
- entry: import.meta.url,
23
- command: "npm run worker:deploy"
24
- });
25
- const run = promisify(execFile);
26
- const NVDA_DIR = workerSourceDir();
27
- const GUEST_DIR = "C:\\Users\\witness\\a11y-witness\\src\\capture\\nvda";
28
- const CTL = fleetScriptPaths().workerCtl;
29
- const LIFECYCLE_TIMEOUT_MS = 420000;
30
- const POOL_TIMEOUT_MS = 240000;
31
- const deploy_worker_HEALTH_TIMEOUT_MS = 20000;
32
- const only = flagValue(process.argv, "vm");
33
- function hashedFiles() {
34
- return WORKER_FILES;
35
- }
36
- function localVersion() {
37
- return codeVersion(NVDA_DIR);
38
- }
39
- async function pool() {
40
- const { stdout } = await run(CTL, [
41
- "pool"
42
- ], {
43
- timeout: POOL_TIMEOUT_MS,
44
- encoding: "utf8"
45
- });
46
- const all = JSON.parse(stdout);
47
- return only ? all.filter((vm)=>vm.name === only) : all;
48
- }
49
- function ctl(action, vmName) {
50
- return run(CTL, [
51
- action
52
- ], {
53
- timeout: LIFECYCLE_TIMEOUT_MS,
54
- encoding: "utf8",
55
- env: {
56
- ...process.env,
57
- A11Y_VM_NAME: vmName
58
- }
59
- });
60
- }
61
- function push(uuid, file) {
62
- return new Promise((done, fail)=>{
63
- const child = execFile("utmctl", [
64
- "file",
65
- "push",
66
- uuid,
67
- `${GUEST_DIR}\\${file}`
68
- ], (error)=>error ? fail(new Error(`push ${file}: ${error.message}`)) : done());
69
- if (child.stdin) createReadStream(external_node_path_resolve(NVDA_DIR, file)).pipe(child.stdin);
70
- });
71
- }
72
- async function healthCode(ip, port) {
73
- const response = await worker_http_requestJson(`http://${ip}:${port}/health`, {
74
- timeoutMs: deploy_worker_HEALTH_TIMEOUT_MS
75
- });
76
- if (!response.ok) throw new Error(`HTTP ${response.status} from /health`);
77
- if (void 0 === response.json) throw new Error(`invalid JSON from http://${ip}:${port}/health`);
78
- return response.json.code;
79
- }
80
- const VERIFY_BUDGET_MS = 240000;
81
- const VERIFY_POLL_MS = 10000;
82
- async function healthCodeWhenAwake(ip, port) {
83
- const deadline = Date.now() + VERIFY_BUDGET_MS;
84
- let last = "no answer";
85
- let waited = false;
86
- while(Date.now() < deadline)try {
87
- const actual = await healthCode(ip, port);
88
- if (waited) process.stdout.write("\n");
89
- return actual;
90
- } catch (error) {
91
- last = error instanceof Error ? error.message : String(error);
92
- if (!waited) process.stdout.write(" waiting for the guest to answer /health ");
93
- waited = true;
94
- process.stdout.write(".");
95
- await new Promise((resolve)=>setTimeout(resolve, VERIFY_POLL_MS));
96
- }
97
- if (waited) process.stdout.write("\n");
98
- throw new Error(`${ip}:${port} never answered /health within ${VERIFY_BUDGET_MS / 1000}s (last: ${last})`);
99
- }
100
- async function waitUntilSettled(name, limitMs = 120000) {
101
- const deadline = Date.now() + limitMs;
102
- while(Date.now() < deadline){
103
- const vm = (await pool()).find((v)=>v.name === name);
104
- if (!vm || "stopping" !== vm.state) return;
105
- await new Promise((resolve)=>setTimeout(resolve, 5000));
106
- }
107
- process.stdout.write(` note: ${name} is still stopping; continuing anyway\n`);
108
- }
109
- async function deployTo(vm, files, expected) {
110
- process.stdout.write(`\n=== ${vm.name} ===\n`);
111
- await ctl("up", vm.name);
112
- for (const file of files){
113
- await push(vm.uuid, file);
114
- process.stdout.write(` pushed ${file}\n`);
115
- }
116
- process.stdout.write(" rebooting (utmctl exec cannot be trusted to restart the worker) ...\n");
117
- await ctl("stop", vm.name);
118
- await ctl("up", vm.name);
119
- try {
120
- const fresh = await pool();
121
- const back = fresh.find((v)=>v.name === vm.name);
122
- if (!back?.ip) throw new Error(`${vm.name} did not come back with an address`);
123
- const actual = await healthCodeWhenAwake(back.ip, back.port);
124
- const ok = actual === expected;
125
- process.stdout.write(` /health.code ${actual} ${ok ? "== expected" : `!= expected ${expected}`}\n`);
126
- return ok;
127
- } finally{
128
- if ("started" !== vm.state) await ctl("stop", vm.name).catch(()=>void 0);
129
- }
130
- }
131
- function guardProtocolChange() {
132
- const inTree = String(CAPTURE_PROTOCOL_VERSION);
133
- let committed;
134
- try {
135
- committed = /CAPTURE_PROTOCOL_VERSION = (\d+)/.exec(execFileSync("git", [
136
- "-C",
137
- NVDA_DIR,
138
- "show",
139
- "HEAD:./protocol-version.mjs"
140
- ], {
141
- encoding: "utf8",
142
- env: sandboxGitEnv()
143
- }))?.[1];
144
- } catch {
145
- try {
146
- execFileSync("git", [
147
- "rev-parse",
148
- "--verify",
149
- "HEAD"
150
- ], {
151
- stdio: "ignore",
152
- env: sandboxGitEnv()
153
- });
154
- process.stdout.write(` note: cannot compare CAPTURE_PROTOCOL_VERSION against HEAD — ${external_node_path_resolve(NVDA_DIR, "protocol-version.mjs")} is not in HEAD.\n Expected for a brand-new or just-moved file; if the path moved, fix it here or this guard is off.\n`);
155
- } catch {}
156
- return;
157
- }
158
- if (!inTree || !committed || inTree === committed) return;
159
- if (process.argv.includes("--allow-protocol-change")) return void process.stdout.write(`\nDeploying CAPTURE_PROTOCOL_VERSION ${committed} -> ${inTree} as requested. Every cached capture is now invalid and the next run will recapture all of them.\n`);
160
- process.stderr.write(`\nREFUSING TO DEPLOY: the working tree has CAPTURE_PROTOCOL_VERSION = ${inTree}, but HEAD has ${committed}.\n\nThat value is a capture-cache key, so deploying it invalidates all cached captures and forces a\nfull recapture: ${RECAPTURE_COST}. If a \`worker:code\` STALE report sent you here, the stale\nhash is probably caused by this uncommitted bump rather than by the guests being out of date.\n\n git stash # deploy without the bump, or\n npm run worker:deploy -- --allow-protocol-change # deploy it deliberately\n`);
161
- process.exit(3);
162
- }
163
- async function main() {
164
- warnUtmDeprecated("npm run worker:deploy");
165
- guardProtocolChange();
166
- const files = hashedFiles();
167
- const expected = localVersion();
168
- const vms = await pool();
169
- if (!vms.length) {
170
- process.stderr.write(only ? `no local worker VM named ${only}\n` : "no local worker VMs registered\n");
171
- process.exit(2);
172
- }
173
- process.stdout.write(`Deploying ${files.length} file(s) to ${vms.length} worker(s)\n`);
174
- process.stdout.write(`Files: ${files.join(", ")}\nExpected code: ${expected}\n`);
175
- const failed = [];
176
- for (const vm of vms){
177
- try {
178
- if (!await deployTo(vm, files, expected)) failed.push(vm.name);
179
- } catch (error) {
180
- process.stdout.write(` FAILED: ${error.message}\n`);
181
- failed.push(vm.name);
182
- }
183
- await waitUntilSettled(vm.name);
184
- }
185
- process.stdout.write(`\n${vms.length - failed.length}/${vms.length} worker(s) on ${expected}\n`);
186
- if (failed.length) {
187
- process.stdout.write(`stale or failed: ${failed.join(", ")}\n`);
188
- process.stdout.write("Re-run this command; if it persists, the guest is not rebooting — see docs/nvda-worker-runbook.md\n");
189
- }
190
- process.exit(failed.length ? 1 : 0);
191
- }
192
- if (import.meta.url === pathToFileURL(process.argv[1] ? realpathSync(process.argv[1]) : "").href) await main();
@@ -1,4 +0,0 @@
1
- function warnUtmDeprecated(what) {
2
- process.stderr.write(`DEPRECATED: ${what} manages a local UTM worker VM. UTM was a testing path and is not the fleet.\nCapture on the bare-metal fleet instead: npm run fleet:status, npm run fleet:deploy. See CLAUDE.md's\n"Working on a Mac" section.\n`);
3
- }
4
- export { warnUtmDeprecated };