@a11ign/screenreader-fleet 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/code-drift.d.mts +4 -3
- package/dist/fleet-env.d.mts +1 -1
- package/dist/index.mjs +3 -1
- package/dist/worker-code-check.mjs +2 -2
- package/package.json +2 -3
- package/src/local-worker/worker-ctl.sh +1 -1
- package/src/provisioning/bootstrap-windows-worker.ps1 +1 -1
- package/dist/deploy-worker.d.mts +0 -2
- package/dist/deploy-worker.mjs +0 -192
- package/dist/src_utm-deprecated_mjs.mjs +0 -4
package/dist/code-drift.d.mts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
* `utmctl file push` plus a `utmctl` reboot
|
|
36
|
-
*
|
|
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
|
package/dist/fleet-env.d.mts
CHANGED
|
@@ -90,7 +90,7 @@ export function portFromGroupVars(text: string): number;
|
|
|
90
90
|
*
|
|
91
91
|
* Exists so a caller can tell a physical box from a local UTM VM, which decides how it is deployed to and
|
|
92
92
|
* therefore what remedy to print. `worker:code` used to tell every stale worker to run `utmctl` and
|
|
93
|
-
* `npm run worker:deploy
|
|
93
|
+
* `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
94
|
* UUID and fails immediately off macOS. Following that advice on this fleet wastes the time it takes to
|
|
95
95
|
* discover the tool was describing a different kind of machine.
|
|
96
96
|
*
|
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
|
-
|
|
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", "
|
|
37
|
-
if (vms.length) lines.push(`\n ${vms.length} not in inventory.yml — local VM(s).
|
|
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
|
+
"version": "0.4.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",
|
|
@@ -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`
|
|
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
|
-
# - `
|
|
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) {
|
package/dist/deploy-worker.d.mts
DELETED
package/dist/deploy-worker.mjs
DELETED
|
@@ -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 };
|