@leera.io/qa-runner 1.0.0 → 1.0.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.
- package/dist/src/cli/commands/doctor.js +9 -3
- package/dist/src/health.js +309 -0
- package/dist/src/runner.js +16 -1
- package/package.json +1 -1
|
@@ -3,16 +3,18 @@ import { formatDoctor, runDoctor } from '../../doctor.js';
|
|
|
3
3
|
import { isPlatform } from '../../devices/types.js';
|
|
4
4
|
import { createRegistry } from '../../drivers/registry.js';
|
|
5
5
|
import { ExitCode } from '../../errors.js';
|
|
6
|
+
import { envSecrets, toHealthReport } from '../../health.js';
|
|
6
7
|
import { homePaths } from '../../home.js';
|
|
7
8
|
import { printJson, usageError } from '../output.js';
|
|
8
9
|
import { installBrowsers } from './setup.js';
|
|
9
|
-
const usage = `Usage: leera-qa-runner doctor [--json] [--fix] [--platform web|android|ios|electron|tauri]
|
|
10
|
+
const usage = `Usage: leera-qa-runner doctor [--json] [--health] [--fix] [--platform web|android|ios|electron|tauri]
|
|
10
11
|
|
|
11
12
|
Checks that this machine can run jobs: Node.js, the runner home, the server URL and
|
|
12
13
|
token, and each platform's tools. Exits 4 when something needs fixing.
|
|
13
14
|
|
|
14
15
|
Options:
|
|
15
16
|
--json Print the report as JSON
|
|
17
|
+
--health Print the report the runner sends to the server (JSON)
|
|
16
18
|
--fix Fix what can be fixed safely (permissions, installing Chromium)
|
|
17
19
|
--platform NAME Only check one platform
|
|
18
20
|
`;
|
|
@@ -25,6 +27,7 @@ export const command = {
|
|
|
25
27
|
strict: true,
|
|
26
28
|
options: {
|
|
27
29
|
json: { type: 'boolean' },
|
|
30
|
+
health: { type: 'boolean' },
|
|
28
31
|
fix: { type: 'boolean' },
|
|
29
32
|
platform: { type: 'string' },
|
|
30
33
|
offline: { type: 'boolean' },
|
|
@@ -44,11 +47,14 @@ export const command = {
|
|
|
44
47
|
paths,
|
|
45
48
|
registry,
|
|
46
49
|
fix: values.fix ?? false,
|
|
47
|
-
|
|
50
|
+
// `--health` prints exactly what `start` sends, and that report never uses the network.
|
|
51
|
+
offline: (values.offline ?? false) || (values.health ?? false),
|
|
48
52
|
...(platform ? { platform } : {}),
|
|
49
53
|
installBrowsers: async () => (await installBrowsers(io, paths)) === 0,
|
|
50
54
|
});
|
|
51
|
-
if (values.
|
|
55
|
+
if (values.health)
|
|
56
|
+
printJson(io, toHealthReport(report, { secrets: envSecrets(io.env) }));
|
|
57
|
+
else if (values.json)
|
|
52
58
|
printJson(io, report);
|
|
53
59
|
else
|
|
54
60
|
io.stdout(formatDoctor(report));
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { TOKEN_PREFIX } from './config.js';
|
|
2
|
+
import { runDoctor } from './doctor.js';
|
|
3
|
+
import { isPlatform } from './devices/types.js';
|
|
4
|
+
import { messageOf } from './errors.js';
|
|
5
|
+
import { redactor } from './template.js';
|
|
6
|
+
/** Contracts §O limits. The server refuses a report that exceeds any of them. */
|
|
7
|
+
export const MAX_HEALTH_CHECKS = 60;
|
|
8
|
+
export const MAX_HEALTH_BYTES = 16 * 1024;
|
|
9
|
+
export const MAX_HEALTH_DETAIL = 300;
|
|
10
|
+
export const MAX_HEALTH_LABEL = 80;
|
|
11
|
+
export const MAX_HEALTH_FIX_COMMAND = 200;
|
|
12
|
+
export const MAX_HEALTH_FIX_URL = 300;
|
|
13
|
+
const HEALTH_ID = /^[a-z][a-z0-9._-]{0,60}$/;
|
|
14
|
+
/**
|
|
15
|
+
* Checks that depend on the network. They are left out of the report: a five-second outage
|
|
16
|
+
* or a proxy hiccup would otherwise show up on the runners page as a setup problem the
|
|
17
|
+
* person cannot fix, and the page already knows whether the runner is reachable (it is
|
|
18
|
+
* talking to the server whenever a report arrives at all).
|
|
19
|
+
*/
|
|
20
|
+
export const NETWORK_CHECK_IDS = new Set(['server']);
|
|
21
|
+
/** `doctor` titles the bare platform checks with the platform name; the UI wants a label. */
|
|
22
|
+
const PLATFORM_LABELS = {
|
|
23
|
+
web: 'Web jobs',
|
|
24
|
+
android: 'Android jobs',
|
|
25
|
+
ios: 'iOS jobs',
|
|
26
|
+
electron: 'Electron jobs',
|
|
27
|
+
tauri: 'Tauri jobs',
|
|
28
|
+
macos: 'macOS app jobs',
|
|
29
|
+
windows: 'Windows app jobs',
|
|
30
|
+
linux: 'Linux app jobs',
|
|
31
|
+
};
|
|
32
|
+
/** First words that make a fix line a command someone can paste, rather than prose. */
|
|
33
|
+
const COMMAND_WORDS = new Set([
|
|
34
|
+
'leera-qa-runner',
|
|
35
|
+
'npm',
|
|
36
|
+
'npx',
|
|
37
|
+
'node',
|
|
38
|
+
'sdkmanager',
|
|
39
|
+
'avdmanager',
|
|
40
|
+
'adb',
|
|
41
|
+
'brew',
|
|
42
|
+
'winget',
|
|
43
|
+
'choco',
|
|
44
|
+
'sudo',
|
|
45
|
+
'apt',
|
|
46
|
+
'apt-get',
|
|
47
|
+
'cargo',
|
|
48
|
+
'pip',
|
|
49
|
+
'pip3',
|
|
50
|
+
'gem',
|
|
51
|
+
'xcode-select',
|
|
52
|
+
'xcrun',
|
|
53
|
+
'xcodebuild',
|
|
54
|
+
'softwareupdate',
|
|
55
|
+
'automationmodetool',
|
|
56
|
+
'sh',
|
|
57
|
+
'bash',
|
|
58
|
+
'zsh',
|
|
59
|
+
'powershell',
|
|
60
|
+
'msiexec',
|
|
61
|
+
'defaults',
|
|
62
|
+
'docker',
|
|
63
|
+
'git',
|
|
64
|
+
'chmod',
|
|
65
|
+
]);
|
|
66
|
+
const TOKEN_PATTERN = new RegExp(`${TOKEN_PREFIX}[A-Za-z0-9_.-]+`, 'g');
|
|
67
|
+
/** `scheme://user:password@host` — a URL somebody configured with credentials in it. */
|
|
68
|
+
const URL_USERINFO = /([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi;
|
|
69
|
+
export function truncate(text, max) {
|
|
70
|
+
const trimmed = text.trim();
|
|
71
|
+
return trimmed.length <= max ? trimmed : `${trimmed.slice(0, max - 1).trimEnd()}…`;
|
|
72
|
+
}
|
|
73
|
+
/** The token sources a report could otherwise quote back. */
|
|
74
|
+
export function envSecrets(env) {
|
|
75
|
+
return [env.RUNNER_TOKEN, env.LEERA_RUNNER_TOKEN, env.RUNNER_WEBHOOK_SECRET]
|
|
76
|
+
.map((value) => value?.trim())
|
|
77
|
+
.filter((value) => !!value);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Removes every known secret, anything shaped like a runner token, and the user:password
|
|
81
|
+
* part of a URL. Paths are deliberately kept (contracts §O).
|
|
82
|
+
*/
|
|
83
|
+
export function scrubber(secrets) {
|
|
84
|
+
const redact = redactor(secrets);
|
|
85
|
+
return (text) => redact(text).replace(TOKEN_PATTERN, `${TOKEN_PREFIX}[redacted]`).replace(URL_USERINFO, '$1[redacted]@');
|
|
86
|
+
}
|
|
87
|
+
/** Makes an id the server accepts (`^[a-z][a-z0-9._-]{0,60}$`) without changing ids that already do. */
|
|
88
|
+
export function healthCheckId(id) {
|
|
89
|
+
if (HEALTH_ID.test(id))
|
|
90
|
+
return id;
|
|
91
|
+
const cleaned = id
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
94
|
+
.replace(/^[^a-z]+/, '')
|
|
95
|
+
.slice(0, 61);
|
|
96
|
+
return cleaned.length > 0 ? cleaned : 'check';
|
|
97
|
+
}
|
|
98
|
+
function platformOf(id) {
|
|
99
|
+
const head = id.split('.')[0] ?? '';
|
|
100
|
+
return isPlatform(head) ? head : undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Turns a `doctor` fix line into `{command, url}`. A line inside backticks is the command
|
|
104
|
+
* (`… then run \`leera-qa-runner setup ios\``); a bare line that starts with a known tool is
|
|
105
|
+
* itself the command; anything else is advice and stays in `detail` so nothing is lost.
|
|
106
|
+
*/
|
|
107
|
+
export function parseFix(text) {
|
|
108
|
+
const line = text.trim();
|
|
109
|
+
if (!line)
|
|
110
|
+
return {};
|
|
111
|
+
const fix = {};
|
|
112
|
+
const quoted = /`([^`]+)`/.exec(line)?.[1]?.trim();
|
|
113
|
+
let command = quoted;
|
|
114
|
+
let prose = quoted ? undefined : line;
|
|
115
|
+
if (!command) {
|
|
116
|
+
const head = line.split(/\s+/)[0] ?? '';
|
|
117
|
+
if (COMMAND_WORDS.has(head)) {
|
|
118
|
+
// "leera-qa-runner config set tauri.macos_plugin true (only when …)" — drop the aside.
|
|
119
|
+
command = (line.split(' (')[0] ?? line).trim();
|
|
120
|
+
prose = undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (command && command.length <= MAX_HEALTH_FIX_COMMAND)
|
|
124
|
+
fix.command = command;
|
|
125
|
+
else if (command)
|
|
126
|
+
prose = line;
|
|
127
|
+
const url = /https?:\/\/[^\s)`'"<>]+/.exec(line)?.[0];
|
|
128
|
+
if (url && url.length <= MAX_HEALTH_FIX_URL)
|
|
129
|
+
fix.url = url;
|
|
130
|
+
return {
|
|
131
|
+
...(fix.command || fix.url ? { fix } : {}),
|
|
132
|
+
...(prose ? { prose } : {}),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const WORST = { ok: 0, warn: 1, fail: 2 };
|
|
136
|
+
export function deriveStatus(checks) {
|
|
137
|
+
if (checks.some((check) => check.status === 'fail'))
|
|
138
|
+
return 'action_needed';
|
|
139
|
+
if (checks.some((check) => check.status === 'warn'))
|
|
140
|
+
return 'warning';
|
|
141
|
+
return 'ok';
|
|
142
|
+
}
|
|
143
|
+
function toHealthCheck(check, scrub) {
|
|
144
|
+
const id = healthCheckId(check.id);
|
|
145
|
+
const platform = platformOf(id);
|
|
146
|
+
const parsed = check.fix && check.status !== 'ok' ? parseFix(scrub(check.fix)) : {};
|
|
147
|
+
const detail = [check.detail ? scrub(check.detail) : '', parsed.prose ? `fix: ${parsed.prose}` : '']
|
|
148
|
+
.filter((part) => part.length > 0)
|
|
149
|
+
.join(' — ');
|
|
150
|
+
return {
|
|
151
|
+
id,
|
|
152
|
+
label: truncate(PLATFORM_LABELS[check.id] ?? check.title, MAX_HEALTH_LABEL),
|
|
153
|
+
...(platform ? { platform } : {}),
|
|
154
|
+
status: check.status,
|
|
155
|
+
...(detail ? { detail: truncate(detail, MAX_HEALTH_DETAIL) } : {}),
|
|
156
|
+
...(parsed.fix ? { fix: parsed.fix } : {}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** Keeps one check per id: the worst status wins, so a later failure is never hidden. */
|
|
160
|
+
function dedupe(checks) {
|
|
161
|
+
const byId = new Map();
|
|
162
|
+
for (const check of checks) {
|
|
163
|
+
const seen = byId.get(check.id);
|
|
164
|
+
if (!seen || WORST[check.status] > WORST[seen.status])
|
|
165
|
+
byId.set(check.id, check);
|
|
166
|
+
}
|
|
167
|
+
return [...byId.values()];
|
|
168
|
+
}
|
|
169
|
+
function capChecks(checks) {
|
|
170
|
+
if (checks.length <= MAX_HEALTH_CHECKS)
|
|
171
|
+
return checks;
|
|
172
|
+
const problems = checks.filter((check) => check.status !== 'ok');
|
|
173
|
+
const rest = checks.filter((check) => check.status === 'ok');
|
|
174
|
+
return [...problems, ...rest].slice(0, MAX_HEALTH_CHECKS);
|
|
175
|
+
}
|
|
176
|
+
export function serializedBytes(report) {
|
|
177
|
+
return Buffer.byteLength(JSON.stringify(report), 'utf8');
|
|
178
|
+
}
|
|
179
|
+
function withoutDetail(check) {
|
|
180
|
+
return {
|
|
181
|
+
id: check.id,
|
|
182
|
+
label: check.label,
|
|
183
|
+
...(check.platform ? { platform: check.platform } : {}),
|
|
184
|
+
status: check.status,
|
|
185
|
+
...(check.fix ? { fix: check.fix } : {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Ways to shrink an oversized report, tried in order; problems and their fixes go last. */
|
|
189
|
+
const SHRINK = [
|
|
190
|
+
(checks) => checks.map((check) => (check.status === 'ok' ? withoutDetail(check) : check)),
|
|
191
|
+
(checks) => checks.filter((check) => check.status !== 'ok'),
|
|
192
|
+
(checks) => checks.map((check) => (check.detail ? { ...check, detail: truncate(check.detail, 120) } : check)),
|
|
193
|
+
(checks) => checks.map(withoutDetail),
|
|
194
|
+
];
|
|
195
|
+
function fit(report) {
|
|
196
|
+
let current = report;
|
|
197
|
+
for (const shrink of SHRINK) {
|
|
198
|
+
if (serializedBytes(current) <= MAX_HEALTH_BYTES)
|
|
199
|
+
return current;
|
|
200
|
+
current = { ...current, checks: shrink(current.checks) };
|
|
201
|
+
}
|
|
202
|
+
while (serializedBytes(current) > MAX_HEALTH_BYTES && current.checks.length > 0) {
|
|
203
|
+
current = { ...current, checks: current.checks.slice(0, Math.floor(current.checks.length / 2)) };
|
|
204
|
+
}
|
|
205
|
+
return current;
|
|
206
|
+
}
|
|
207
|
+
/** Builds the report the server stores from a `doctor` run (contracts §O). */
|
|
208
|
+
export function toHealthReport(report, options = {}) {
|
|
209
|
+
const scrub = scrubber(options.secrets ?? []);
|
|
210
|
+
const checks = dedupe(report.checks.filter((check) => !NETWORK_CHECK_IDS.has(check.id)).map((check) => toHealthCheck(check, scrub)));
|
|
211
|
+
// The summary covers every check, including any the size limits drop below.
|
|
212
|
+
return fit({ generated_at: (options.now ?? (() => new Date()))().toISOString(), status: deriveStatus(checks), checks: capChecks(checks) });
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Runs the `doctor` checks and turns them into a health report. Never fixes anything and
|
|
216
|
+
* never touches the network (`offline`), so it is safe to run while jobs are in flight.
|
|
217
|
+
*/
|
|
218
|
+
export async function healthReport(options) {
|
|
219
|
+
const report = await runDoctor({ env: options.env, paths: options.paths, registry: options.registry, fix: false, offline: true });
|
|
220
|
+
return toHealthReport(report, {
|
|
221
|
+
secrets: [...envSecrets(options.env), ...(options.secrets ?? [])],
|
|
222
|
+
...(options.now ? { now: options.now } : {}),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** How often a report may be recomputed, whatever asks for it (contracts §O). */
|
|
226
|
+
export const HEALTH_MIN_INTERVAL_MS = 60_000;
|
|
227
|
+
/** How long to wait for the checks before giving up on this round and keeping the last report. */
|
|
228
|
+
export const HEALTH_TIMEOUT_MS = 120_000;
|
|
229
|
+
function defaultTimer(fn, ms) {
|
|
230
|
+
const timer = setTimeout(fn, ms);
|
|
231
|
+
timer.unref?.();
|
|
232
|
+
return { cancel: () => clearTimeout(timer) };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Keeps the last health report and recomputes it in the background. Registration never waits
|
|
236
|
+
* for it: the first register carries no report, the next one does. At most one computation
|
|
237
|
+
* runs at a time (the checks shell out to adb, xcrun and java) and at most one a minute.
|
|
238
|
+
*/
|
|
239
|
+
export class HealthMonitor {
|
|
240
|
+
deps;
|
|
241
|
+
report = null;
|
|
242
|
+
running = null;
|
|
243
|
+
lastStartedAt = null;
|
|
244
|
+
constructor(deps) {
|
|
245
|
+
this.deps = deps;
|
|
246
|
+
}
|
|
247
|
+
/** The last computed report, or null while none has finished yet. */
|
|
248
|
+
current() {
|
|
249
|
+
return this.report;
|
|
250
|
+
}
|
|
251
|
+
/** Resolves when a computation in flight has been stored or timed out (tests). */
|
|
252
|
+
settled() {
|
|
253
|
+
return this.running ?? Promise.resolve();
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Starts a computation unless one is already running or one started less than a minute ago.
|
|
257
|
+
* The returned promise is for tests; callers fire and forget.
|
|
258
|
+
*/
|
|
259
|
+
refresh(trigger) {
|
|
260
|
+
const now = (this.deps.now ?? Date.now)();
|
|
261
|
+
if (this.running)
|
|
262
|
+
return this.running;
|
|
263
|
+
const minInterval = this.deps.minIntervalMs ?? HEALTH_MIN_INTERVAL_MS;
|
|
264
|
+
if (this.lastStartedAt !== null && now - this.lastStartedAt < minInterval)
|
|
265
|
+
return Promise.resolve();
|
|
266
|
+
this.lastStartedAt = now;
|
|
267
|
+
let raw;
|
|
268
|
+
try {
|
|
269
|
+
raw = this.deps.compute();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
this.deps.log.warn('health checks failed', { trigger, error: messageOf(error) });
|
|
273
|
+
return Promise.resolve();
|
|
274
|
+
}
|
|
275
|
+
// The lock is held until the checks really finish, even if we stopped waiting for them,
|
|
276
|
+
// so a slow adb or xcrun is never started a second time alongside itself.
|
|
277
|
+
const lock = raw.then(() => undefined, () => undefined);
|
|
278
|
+
this.running = lock;
|
|
279
|
+
void lock.then(() => {
|
|
280
|
+
if (this.running === lock)
|
|
281
|
+
this.running = null;
|
|
282
|
+
});
|
|
283
|
+
return this.store(raw, trigger);
|
|
284
|
+
}
|
|
285
|
+
async store(raw, trigger) {
|
|
286
|
+
const timeoutMs = this.deps.timeoutMs ?? HEALTH_TIMEOUT_MS;
|
|
287
|
+
const timer = this.deps.setTimer ?? defaultTimer;
|
|
288
|
+
const handles = [];
|
|
289
|
+
const expired = new Promise((resolve) => {
|
|
290
|
+
handles.push(timer(() => resolve('timeout'), timeoutMs));
|
|
291
|
+
});
|
|
292
|
+
try {
|
|
293
|
+
const outcome = await Promise.race([raw, expired]);
|
|
294
|
+
if (outcome === 'timeout') {
|
|
295
|
+
this.deps.log.warn('health checks are taking too long; keeping the previous report', { trigger, timeout_ms: timeoutMs });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
this.report = outcome;
|
|
299
|
+
this.deps.log.debug('health report updated', { trigger, status: outcome.status, checks: outcome.checks.length });
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
this.deps.log.warn('health checks failed', { trigger, error: messageOf(error) });
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
for (const handle of handles)
|
|
306
|
+
handle.cancel();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
package/dist/src/runner.js
CHANGED
|
@@ -9,6 +9,7 @@ import { createRegistry } from './drivers/registry.js';
|
|
|
9
9
|
import { BrowserPool } from './drivers/web-playwright.js';
|
|
10
10
|
import { ExitCode, ExitError, messageOf } from './errors.js';
|
|
11
11
|
import { createDriver, executeClaimedJob, jobContext, sessionContext } from './executor.js';
|
|
12
|
+
import { HealthMonitor, healthReport } from './health.js';
|
|
12
13
|
import { homePaths, readJsonFile, writePrivateFile } from './home.js';
|
|
13
14
|
import { redactor } from './template.js';
|
|
14
15
|
import { runSession } from './session/loop.js';
|
|
@@ -170,6 +171,7 @@ export class Runner {
|
|
|
170
171
|
const platform = job.platform ?? 'web';
|
|
171
172
|
const lease = isPlatform(platform) ? devices.lease(platform, job.device?.id, { holder: `job:${job.job.id}` }) : null;
|
|
172
173
|
if (!lease) {
|
|
174
|
+
void this.deps.health?.refresh('job_failed');
|
|
173
175
|
await client
|
|
174
176
|
.complete({ job_id: job.job.id, error: `this runner has no free ${platform} device for the job` })
|
|
175
177
|
.catch((error) => log.error('could not report the failure', { job_id: job.job.id, error: messageOf(error) }));
|
|
@@ -188,6 +190,7 @@ export class Runner {
|
|
|
188
190
|
? devices.lease(session.platform, session.device?.id, { holder: `session:${session.id}` })
|
|
189
191
|
: null;
|
|
190
192
|
if (!lease) {
|
|
193
|
+
void this.deps.health?.refresh('job_failed');
|
|
191
194
|
await client
|
|
192
195
|
.endSession(session.id, `this runner has no free ${session.platform} device for the session`)
|
|
193
196
|
.catch((error) => log.error('could not end the session', { session_id: session.id, error: messageOf(error) }));
|
|
@@ -255,7 +258,10 @@ export class Runner {
|
|
|
255
258
|
let delay = 2_000;
|
|
256
259
|
while (!this.stopping) {
|
|
257
260
|
try {
|
|
258
|
-
|
|
261
|
+
// The report is whatever the background checks last produced: the first registration
|
|
262
|
+
// carries none, later ones do (contracts §O). Registration never waits for it.
|
|
263
|
+
const health = this.deps.health?.current();
|
|
264
|
+
const registration = await client.register({ ...body, ...(health ? { health } : {}) });
|
|
259
265
|
if (this.registration?.runner_id !== registration.runner_id) {
|
|
260
266
|
log.info('registered', {
|
|
261
267
|
runner_id: registration.runner_id,
|
|
@@ -266,6 +272,8 @@ export class Runner {
|
|
|
266
272
|
this.noteLatestVersion(registration.latest_version, body.version);
|
|
267
273
|
this.registration = registration;
|
|
268
274
|
this.writeState();
|
|
275
|
+
// Recompute for the next registration (device change or the 5-minute refresh).
|
|
276
|
+
void this.deps.health?.refresh('register');
|
|
269
277
|
return registration;
|
|
270
278
|
}
|
|
271
279
|
catch (error) {
|
|
@@ -398,6 +406,9 @@ export class Runner {
|
|
|
398
406
|
catch (error) {
|
|
399
407
|
const reason = redact(messageOf(error));
|
|
400
408
|
log.error('job could not run', { job_id: jobId, error: reason });
|
|
409
|
+
// A job that could not start often means the machine's setup changed (a driver, a
|
|
410
|
+
// device, a permission); refresh the report so the runners page says what broke.
|
|
411
|
+
void this.deps.health?.refresh('job_failed');
|
|
401
412
|
await client
|
|
402
413
|
.complete({ job_id: jobId, error: reason })
|
|
403
414
|
.catch((e) => log.error('could not report the failure', { job_id: jobId, error: redact(messageOf(e)) }));
|
|
@@ -491,6 +502,10 @@ export async function createRunner(options) {
|
|
|
491
502
|
host,
|
|
492
503
|
shared,
|
|
493
504
|
log: options.log,
|
|
505
|
+
health: new HealthMonitor({
|
|
506
|
+
compute: () => healthReport({ env, paths, registry, secrets: [config.token] }),
|
|
507
|
+
log: options.log,
|
|
508
|
+
}),
|
|
494
509
|
...(options.useHome === false ? {} : { paths }),
|
|
495
510
|
...(options.writeState ? { statePath: paths.state } : {}),
|
|
496
511
|
});
|
package/package.json
CHANGED