@volter/supercode-health 0.1.20 → 0.1.21
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/lib/promises.mjs +68 -16
- package/package.json +1 -1
- package/promises.json +1 -1
package/lib/promises.mjs
CHANGED
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
// zero) and readable there the whole time. The probe (probe.mjs) runs the pass every slow interval and mails a false
|
|
11
11
|
// promise to the `core` addresses on the pass that first reads it false; a release's activation watch runs the same
|
|
12
12
|
// pass once the new daemon serves and compares it with the last pass before it.
|
|
13
|
+
//
|
|
14
|
+
// A pass taken for a release (`since`: when that release's daemon began to serve) counts only what happened under it.
|
|
15
|
+
// A promise that reads an event (a board's last round, a mail's expiry) reads there only events that began at or after
|
|
16
|
+
// `since`: a round that met no daemon while an install restarted it says nothing of the release that came up. Until
|
|
17
|
+
// such an event exists the promise is unknown for that release, never true and never false.
|
|
13
18
|
import { readFileSync, realpathSync, statfsSync, statSync, accessSync, constants } from 'node:fs';
|
|
14
19
|
import { homedir, tmpdir, userInfo } from 'node:os';
|
|
15
20
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
@@ -145,6 +150,28 @@ function dispatcherHomes(context, hours = 24) {
|
|
|
145
150
|
});
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/** A kept release's promises that have read true under it (activation time → names), as this process has read them. */
|
|
154
|
+
const releaseHeldSeen = new Map();
|
|
155
|
+
|
|
156
|
+
/** The promises that have read true under the release activated at `at`: from this process and from the probe's
|
|
157
|
+
* snapshot, so it holds across a probe's restart and for a pass taken by hand. */
|
|
158
|
+
function releaseHeld(context, at) {
|
|
159
|
+
const kept = json(join(healthDir(context.env), 'latest.json'))?.readings?.promises?.items?.find((item) => item.name === 'release-kept')?.detail;
|
|
160
|
+
return new Set(at == null ? [] : [...(kept?.at_ms === at ? kept.held ?? [] : []), ...(releaseHeldSeen.get(at) ?? [])]);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** One promise of the list read inside its own bound: past it, or when its probe throws, it is unknown and says so. */
|
|
164
|
+
async function readOne(entry, context) {
|
|
165
|
+
let timer;
|
|
166
|
+
const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(unknown(`not read within its ${entry.bound_ms} ms bound`)), entry.bound_ms); });
|
|
167
|
+
const probe = PROBES[entry.probe];
|
|
168
|
+
const read = probe ? Promise.resolve().then(() => probe(context)).catch((error) => unknown(`could not be read: ${String(error.message ?? error).split('\n')[0].slice(0, 240)}`))
|
|
169
|
+
: Promise.resolve(unknown(`this release has no probe named ${entry.probe}`));
|
|
170
|
+
const result = await Promise.race([read, bound]);
|
|
171
|
+
clearTimeout(timer);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
148
175
|
const PROBES = {
|
|
149
176
|
async server(context) {
|
|
150
177
|
const items = await machineList(context);
|
|
@@ -183,7 +210,7 @@ const PROBES = {
|
|
|
183
210
|
const answer = await daemonCall('harness.v1.mail.expired', {}, { env: context.env, timeoutMs: 1300 });
|
|
184
211
|
if (!Array.isArray(answer?.rows)) return no('the daemon answered its mail door with no list of expiries');
|
|
185
212
|
if (record.mail_ready !== true) return no(`the daemon (pid ${record.pid}) answers and does not serve mail`);
|
|
186
|
-
const since = context.now - context.lines.mailExpiryMin * 60_000;
|
|
213
|
+
const since = Math.max(context.now - context.lines.mailExpiryMin * 60_000, context.since ?? 0);
|
|
187
214
|
const recent = answer.rows.filter((row) => Number(row?.expired_at_ms) >= since);
|
|
188
215
|
const reasons = [...new Set(recent.map((row) => String(row.reason ?? 'no reason given').slice(0, 80)))];
|
|
189
216
|
const detail = { pid: record.pid, expired_recently: recent.length, reasons };
|
|
@@ -301,17 +328,20 @@ const PROBES = {
|
|
|
301
328
|
|
|
302
329
|
async board(context) {
|
|
303
330
|
const held = json(join(supercodeHome(context.env), 'board-homes.json')) ?? {};
|
|
304
|
-
const line = context.lines.boardRoundMin * 60_000, rounds = [], broken = [];
|
|
331
|
+
const line = context.lines.boardRoundMin * 60_000, rounds = [], broken = [], before = [];
|
|
305
332
|
for (const root of new Set(Object.values(held).filter((value) => typeof value === 'string'))) {
|
|
306
333
|
const beat = json(join(root, 'dispatcher-heartbeat.json'));
|
|
307
334
|
if (!beat?.at) continue; // no dispatcher has run a round in this home
|
|
308
335
|
const age = context.now - Date.parse(beat.at);
|
|
309
336
|
const failed = (beat.boards ?? []).reduce((sum, board) => sum + (Number(board.failed) || 0), 0), errors = (beat.errors ?? []).length;
|
|
310
|
-
rounds.push({ home: root, round: beat.round ?? null, at: beat.at, failed, errors });
|
|
337
|
+
rounds.push({ home: root, round: beat.round ?? null, began: beat.began ?? null, at: beat.at, failed, errors });
|
|
338
|
+
// rounds that stopped are false whenever they are read; what a round met is read only of a round begun under `since`
|
|
311
339
|
if (!(age <= line)) broken.push(`${root}: its last round (${beat.round ?? '?'}) ended ${ago(age)} ago`);
|
|
340
|
+
else if (context.since != null && !(Date.parse(beat.began ?? '') >= context.since)) before.push(`${root}: round ${beat.round ?? '?'} began before the daemon that serves now did`);
|
|
312
341
|
else if (failed || errors) broken.push(`${root}: round ${beat.round ?? '?'} failed ${failed} and raised ${errors} error(s)`);
|
|
313
342
|
}
|
|
314
343
|
if (!rounds.length) return yes('no board dispatcher has run a round on this machine');
|
|
344
|
+
if (!broken.length && before.length) return unknown(`${before.join('; ')}; no round has begun and ended under it yet`);
|
|
315
345
|
return broken.length ? no(broken.join('; '), rounds) : yes(rounds.map((r) => `${r.home}: round ${r.round ?? '?'} ${ago(context.now - Date.parse(r.at))} ago, failed 0`).join('; '), rounds);
|
|
316
346
|
},
|
|
317
347
|
|
|
@@ -329,6 +359,11 @@ const PROBES = {
|
|
|
329
359
|
: yes(rows.map((row) => `${row.folder}: ${row.freeGB} GB free`).join('; '), rows);
|
|
330
360
|
},
|
|
331
361
|
|
|
362
|
+
// The watch's verdict (activation-watch.mjs) is one pass taken seconds after the daemon served, and is not the last
|
|
363
|
+
// word on a kept release. A promise it read false there, or could not read yet (a board whose first round under
|
|
364
|
+
// the release had not ended), is read again here as the watch reads it: under the release's daemon only. False is
|
|
365
|
+
// the charge; true ends it for this activation, kept from pass to pass (this process's memory and the last pass in
|
|
366
|
+
// the probe's snapshot) so nothing that fails later under the same release is laid at its activation.
|
|
332
367
|
async activation(context) {
|
|
333
368
|
const verdict = json(join(teamsHome(context.env), 'service', 'last-activation.json'));
|
|
334
369
|
if (!verdict?.state) return yes('no activation is recorded on this machine');
|
|
@@ -338,30 +373,47 @@ const PROBES = {
|
|
|
338
373
|
return no(`${release} was ${verdict.state === 'rolled_back' ? `put back at ${at}; ${verdict.restored?.release ?? 'the release before'} serves` : verdict.state === 'restarted' ? `stopped and started again at ${at}` : `stopped at ${at} and nothing was restored`}: ${verdict.reason ?? verdict.detail ?? 'no reason recorded'}`,
|
|
339
374
|
{ state: verdict.state, at_ms: verdict.at_ms ?? null, broken: verdict.promises?.broken ?? [] });
|
|
340
375
|
}
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
376
|
+
const list = promiseList().promises;
|
|
377
|
+
const own = (row) => list.find((entry) => entry.name === row.name)?.probe === 'activation';
|
|
378
|
+
const charged = (verdict.promises?.broken ?? []).filter((row) => !own(row));
|
|
379
|
+
const awaited = (verdict.promises?.unread ?? []).filter((row) => row.before === 'true' && !own(row));
|
|
380
|
+
const held = releaseHeld(context, verdict.at_ms ?? null);
|
|
381
|
+
const under = { ...context, since: verdict.serving_since_ms ?? null };
|
|
382
|
+
const open = [...charged, ...awaited].filter((row) => !held.has(row.name));
|
|
383
|
+
const read = await Promise.all(open.map((row) => {
|
|
384
|
+
const entry = list.find((item) => item.name === row.name);
|
|
385
|
+
return entry ? readOne(entry, under) : unknown('this release has no promise of that name');
|
|
386
|
+
}));
|
|
387
|
+
const broken = [], unread = [];
|
|
388
|
+
open.forEach((row, i) => {
|
|
389
|
+
if (read[i].state === 'true') held.add(row.name);
|
|
390
|
+
else if (read[i].state === 'false') broken.push({ ...row, reading: read[i].reading });
|
|
391
|
+
else if (charged.includes(row)) broken.push(row); // unread now: what the watch read stands
|
|
392
|
+
else unread.push(row.name);
|
|
393
|
+
});
|
|
394
|
+
releaseHeldSeen.clear();
|
|
395
|
+
if (verdict.at_ms != null) releaseHeldSeen.set(verdict.at_ms, [...held]);
|
|
396
|
+
const detail = { state: verdict.state, at_ms: verdict.at_ms ?? null, broken, held: [...held], unread };
|
|
397
|
+
if (broken.length) return no(`${release} was kept at ${at} and broke ${broken.map((b) => b.name).join(', ')}: ${verdict.promises.kept_because ?? 'it was not put back'}`, detail);
|
|
398
|
+
const cleared = charged.filter((row) => held.has(row.name)).map((row) => row.name);
|
|
399
|
+
return yes(`${release} ${verdict.state} at ${at}${cleared.length ? `; ${cleared.join(', ')} read false at its activation and ${cleared.length === 1 ? 'holds' : 'hold'} under it` : ''}${unread.length ? `; ${unread.join(', ')} not read under it yet` : ''}`,
|
|
400
|
+
charged.length || awaited.length ? detail : undefined);
|
|
344
401
|
},
|
|
345
402
|
};
|
|
346
403
|
|
|
347
404
|
/**
|
|
348
405
|
* One pass: `{ version, at, machine, ms, items: [{ name, promise, state, reading, detail?, ms, bound_ms, activation }] }`.
|
|
349
406
|
* Every probe runs at once; one that has not answered inside its bound is `unknown` and says so, and the pass ends
|
|
350
|
-
* with the slowest probe. `only` reads some of the list by name.
|
|
407
|
+
* with the slowest probe. `only` reads some of the list by name. `since` (ms) takes the pass for the release whose
|
|
408
|
+
* daemon began to serve then: an event that began before it is not read.
|
|
351
409
|
*/
|
|
352
|
-
export async function readPromises({ env = process.env, config = {}, machine = null, only = null, now = Date.now() } = {}) {
|
|
410
|
+
export async function readPromises({ env = process.env, config = {}, machine = null, only = null, now = Date.now(), since = null } = {}) {
|
|
353
411
|
const started = performance.now();
|
|
354
|
-
const context = { env, now, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null, processes: null };
|
|
412
|
+
const context = { env, now, since, bin: env.SUPERCODE_BIN || 'supercode', machine: machineName(machine || env.SUPERCODE_HEALTH_MACHINE || undefined), lines: { ...PROMISE_DEFAULTS, ...(config.promises ?? {}) }, machines: null, processes: null };
|
|
355
413
|
const list = promiseList().promises.filter((entry) => !only || only.includes(entry.name));
|
|
356
414
|
const items = await Promise.all(list.map(async (entry) => {
|
|
357
415
|
const t0 = performance.now();
|
|
358
|
-
|
|
359
|
-
const bound = new Promise((resolve) => { timer = setTimeout(() => resolve(unknown(`not read within its ${entry.bound_ms} ms bound`)), entry.bound_ms); });
|
|
360
|
-
const probe = PROBES[entry.probe];
|
|
361
|
-
const read = probe ? Promise.resolve().then(() => probe(context)).catch((error) => unknown(`could not be read: ${String(error.message ?? error).split('\n')[0].slice(0, 240)}`))
|
|
362
|
-
: Promise.resolve(unknown(`this release has no probe named ${entry.probe}`));
|
|
363
|
-
const result = await Promise.race([read, bound]);
|
|
364
|
-
clearTimeout(timer);
|
|
416
|
+
const result = await readOne(entry, context);
|
|
365
417
|
return { name: entry.name, promise: entry.promise, ...result, ms: Math.round(performance.now() - t0), bound_ms: entry.bound_ms, activation: entry.activation };
|
|
366
418
|
}));
|
|
367
419
|
return { version: 1, at: new Date(now).toISOString(), machine: context.machine, ms: Math.round(performance.now() - started), items };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Optional machine pack: a cheap, deterministic probe of a machine's vitals that mails the machine's maintainer when a threshold is crossed. Each feature sits behind its own permission barrier.",
|
|
6
6
|
"license": "MIT",
|
package/promises.json
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"promise": "Where a board's dispatcher runs, its last round ended in the last ten minutes with nothing failed." },
|
|
23
23
|
{ "name": "disk-free", "probe": "disk", "bound_ms": 500, "activation": "report",
|
|
24
24
|
"promise": "The volumes supercode's home, the person's home and the temporary folder are on each have more than 5 GB free." },
|
|
25
|
-
{ "name": "release-kept", "probe": "activation", "bound_ms":
|
|
25
|
+
{ "name": "release-kept", "probe": "activation", "bound_ms": 7000, "activation": "report",
|
|
26
26
|
"promise": "The last release activated on this machine was kept: it was not put back, and it broke no promise that held before it." },
|
|
27
27
|
{ "name": "release-published", "probe": "release", "bound_ms": 5000, "activation": "report",
|
|
28
28
|
"promise": "What main last tagged for release is published: no release has been built and waiting more than 30 minutes for the version npm installs to reach it. Read on the install's host, from the public registry." },
|