@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 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 broken = verdict.promises?.broken ?? [];
342
- 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'}`, { state: verdict.state, at_ms: verdict.at_ms ?? null, broken });
343
- return yes(`${release} ${verdict.state} at ${at}`);
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
- let timer;
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.20",
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": 200, "activation": "report",
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." },