@volter/supercode-health 0.1.2 → 0.1.3
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/README.md +9 -4
- package/bin/health.mjs +1 -1
- package/lib/format.mjs +1 -1
- package/lib/mail.mjs +17 -39
- package/lib/probe.mjs +28 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,10 +32,15 @@ a minute, 24 h) and `probe.log` to `$SUPERCODE_HOME/health`, which defaults to
|
|
|
32
32
|
|
|
33
33
|
## Alarms
|
|
34
34
|
|
|
35
|
-
`supercode health subscribe sc:<machine
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
`supercode health subscribe sc:<machine>:agent:<name>` subscribes an agent (a maintainer:
|
|
36
|
+
the mailbox delivers to its main session as it is then, so a relaunched main still gets
|
|
37
|
+
it); a session or board address may be subscribed too, and its mail stops reaching it
|
|
38
|
+
when the session ends. Each raise, escalation and clear mails the subscribers. A
|
|
39
|
+
still-raised alarm mails again every `repeatMin` (60). Mail comes from
|
|
40
|
+
`sc:<machine>:operator:machine-health`, filed in each subscriber's mailbox
|
|
41
|
+
(`supercode message file`) after the cycle's reading is written; the mailbox delivers
|
|
42
|
+
it, within its bounds, and `supercode message waiting` / `supercode message expired` list
|
|
43
|
+
what it has not delivered.
|
|
39
44
|
|
|
40
45
|
## Config
|
|
41
46
|
|
package/bin/health.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// status [--json] [--all] this machine's latest reading (latest.json)
|
|
8
8
|
// features [--json] every feature, on or off, its permission barrier and why
|
|
9
9
|
// alarms [--json] the alarms raised now
|
|
10
|
-
// subscribe <address> | unsubscribe <address> alarm mail for
|
|
10
|
+
// subscribe <address> | unsubscribe <address> alarm mail for an agent (sc:<machine>:agent:<name>), session or board
|
|
11
11
|
// config where the config lives and what it currently resolves to
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
13
|
import { parseArgs } from 'node:util';
|
package/lib/format.mjs
CHANGED
|
@@ -10,7 +10,7 @@ export function formatStatus(s, { all = false } = {}) {
|
|
|
10
10
|
if (age > 3 * (s.probe?.intervalSec ?? 15)) out.push(` STALE: the probe has not written for ${age}s; its readings below are old`);
|
|
11
11
|
out.push('', s.alarms.length ? `ALARMS (${s.alarms.length})` : 'ALARMS: none');
|
|
12
12
|
for (const a of s.alarms) out.push(` ${a.level.toUpperCase().padEnd(4)} ${a.summary}${a.detail?.pid ? ` [${a.detail.command?.slice(0, 80)}${who(a.detail)}]` : ''} (since ${a.since})`);
|
|
13
|
-
if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.
|
|
13
|
+
if (s.maintainers) out.push(` mailed to: ${s.maintainers.length ? s.maintainers.join(', ') : 'no maintainer subscribed (supercode health subscribe <address>)'}${s.mail?.error ? ` · last mail not filed: ${s.mail.error}` : s.mail?.filed ? ` · last mail filed ${s.mail.at} (supercode message waiting lists any not yet delivered)` : ''}`);
|
|
14
14
|
if (r.load) out.push('', `load ${r.load.one} / ${r.load.five} / ${r.load.fifteen} on ${r.load.cores} cores (${r.load.perCore}× per core)`);
|
|
15
15
|
if (r.memory) {
|
|
16
16
|
const m = r.memory;
|
package/lib/mail.mjs
CHANGED
|
@@ -1,44 +1,22 @@
|
|
|
1
|
-
// Alarm mail through supercode's
|
|
2
|
-
// `sc:<machine>:operator:machine-health
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Alarm mail, filed in each subscriber's mailbox through supercode's filing door (`supercode message file`), from
|
|
2
|
+
// `sc:<machine>:operator:machine-health`, and delivered by the mailbox (docs/adr/0008-agent-mailbox.md, decision 17;
|
|
3
|
+
// D138 step 6). Filing is a local write: no door, no network, and nothing for the probe to retry. The mailbox resolves
|
|
4
|
+
// an agent address (`sc:<machine>:agent:<name>`) to the agent's main session when it delivers, retries within its
|
|
5
|
+
// bounds, and records what it could not deliver (`supercode message waiting`, `supercode message expired`).
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (Date.now() - item.at > 24 * 3_600_000) { this.items = this.items.filter((x) => x !== item); this.error = `dropped undelivered ${item.id} to ${item.to} after a day`; continue; }
|
|
18
|
-
try { await this.deliver(item); this.items = this.items.filter((x) => x !== item); this.error = null; }
|
|
19
|
-
catch (error) { item.tries += 1; this.error = `mail to ${item.to}: ${error.message}`; }
|
|
20
|
-
}
|
|
21
|
-
this.save();
|
|
22
|
-
return { pending: this.items.length, error: this.error };
|
|
23
|
-
}
|
|
24
|
-
deliver(item) {
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const child = spawn(this.env.SUPERCODE_BIN || 'supercode', ['message', 'remote-deliver'], { env: this.env, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true, shell: process.platform === 'win32' });
|
|
27
|
-
let detail = '';
|
|
28
|
-
const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error('delivery timed out; kept for retry')); }, 30_000);
|
|
29
|
-
child.stdout.on('data', (c) => { detail = (detail + c).slice(-2000); });
|
|
30
|
-
child.stderr.on('data', (c) => { detail = (detail + c).slice(-2000); });
|
|
31
|
-
child.once('error', (error) => { clearTimeout(timer); reject(error); });
|
|
32
|
-
child.once('close', (code) => {
|
|
33
|
-
clearTimeout(timer);
|
|
34
|
-
let receipt; try { receipt = JSON.parse(detail.trim().split('\n').at(-1)); } catch { /* none */ }
|
|
35
|
-
if (code === 0 && receipt?.code === 0) resolve(receipt); else reject(new Error(receipt?.text || detail.trim().slice(-300) || `exit ${code}`));
|
|
36
|
-
});
|
|
37
|
-
child.stdin.on('error', () => {});
|
|
38
|
-
const board = item.to.split(':')[2] === 'board';
|
|
39
|
-
child.stdin.end(JSON.stringify({ op: 'send', from: `sc:${this.machine}:operator:machine-health`, from_name: 'Machine health', to: item.to, id: item.id, subject: item.subject, queue: board, body: item.body }));
|
|
40
|
-
});
|
|
8
|
+
/** File one alarm mail for each address. Answers what was filed and the first failure, if any. */
|
|
9
|
+
export function fileAlarm({ machine, env = process.env, to, key, subject, body }) {
|
|
10
|
+
const filed = [];
|
|
11
|
+
let error = null;
|
|
12
|
+
for (const address of to) {
|
|
13
|
+
const request = { from: `sc:${machine}:operator:machine-health`, from_name: 'Machine health', to: address, id: `${key}-${address}`, subject, body, wake: true };
|
|
14
|
+
const done = spawnSync(env.SUPERCODE_BIN || 'supercode', ['message', 'file'], { env, input: JSON.stringify(request), encoding: 'utf8', timeout: 10_000, windowsHide: true, shell: process.platform === 'win32' });
|
|
15
|
+
let answer = null; try { answer = JSON.parse(String(done.stdout ?? '').trim().split('\n').at(-1)); } catch { /* none */ }
|
|
16
|
+
if (answer?.code === 0) filed.push({ to: address, id: answer.message_id });
|
|
17
|
+
else error ??= `mail to ${address} was not filed: ${answer?.text || String(done.stderr ?? '').trim().slice(-300) || done.error?.message || `exit ${done.status}`}`;
|
|
41
18
|
}
|
|
19
|
+
return { filed, error };
|
|
42
20
|
}
|
|
43
21
|
|
|
44
22
|
const LEVEL = { warn: 'WARN', crit: 'CRIT', none: 'clear' };
|
package/lib/probe.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// The probe: one per OS user, holding an exclusive pidfile. Every cycle it samples, judges each alarm,
|
|
2
|
-
// writes latest.json (what the doors read) and a minute history, and
|
|
2
|
+
// writes latest.json (what the doors read) and a minute history, and then files mail for the maintainers on a change
|
|
3
|
+
// (the mailbox delivers it; nothing the mail does can hold latest.json back).
|
|
3
4
|
// It decides nothing and changes nothing on the machine.
|
|
4
5
|
import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
|
|
5
6
|
import { hostname } from 'node:os';
|
|
@@ -7,7 +8,7 @@ import { healthDir, files as healthFiles, machineName } from './paths.mjs';
|
|
|
7
8
|
import { DEFAULTS, merge, judge, raised } from './thresholds.mjs';
|
|
8
9
|
import { resolveFeatures } from './features.mjs';
|
|
9
10
|
import { Sampler } from './sample.mjs';
|
|
10
|
-
import {
|
|
11
|
+
import { fileAlarm, compose } from './mail.mjs';
|
|
11
12
|
import { run } from './run.mjs';
|
|
12
13
|
|
|
13
14
|
export async function platformReader(platform = process.platform) {
|
|
@@ -76,7 +77,10 @@ export class Probe {
|
|
|
76
77
|
this.config = loadConfig(this.f.config);
|
|
77
78
|
this.configMtime = this.#mtime(this.f.config);
|
|
78
79
|
this.sampler = new Sampler({ reader: this.reader, platform: this.platform, machine: this.machine, env: this.env, config: this.config });
|
|
79
|
-
|
|
80
|
+
// Alarm mail queued here by an earlier pack (outbox.json, retried per recipient until a day old) is not resent: a
|
|
81
|
+
// mail is a reading, superseded by the current one, and its recipients were often sessions that have ended. It is
|
|
82
|
+
// dropped, and every alarm raised now is mailed again at the first cycle, filed in the maintainers' mailboxes.
|
|
83
|
+
this.droppedOutbox = this.#dropOutbox();
|
|
80
84
|
this.extras = {
|
|
81
85
|
rusage: this.reader.rusageAvailable ? await this.reader.rusageAvailable() : false,
|
|
82
86
|
supercode: (await run(this.env.SUPERCODE_BIN || 'supercode', ['--version'], { timeoutMs: 10_000, env: this.env })).ok,
|
|
@@ -84,7 +88,18 @@ export class Probe {
|
|
|
84
88
|
const previous = readJson(this.f.latest, null);
|
|
85
89
|
// Alarms raised before a restart stay raised (and are not re-mailed); their windows restart honestly.
|
|
86
90
|
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
87
|
-
for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
|
|
91
|
+
for (const a of (previous?.alarms ?? []).filter((x) => !x.detail?.pid || alive(x.detail.pid))) this.state[a.key] = { key: a.key, family: a.family, level: a.level, warnSince: null, critSince: null, raisedAt: a.since ? Date.parse(a.since) : Date.now(), lastMailAt: this.droppedOutbox ? 0 : Date.now(), restoredUntil: Date.now() + 2 * 60_000, value: a.value, unit: a.unit, summary: a.summary, detail: a.detail };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Remove an earlier pack's outbox; answers how many mails it held, logged by recipient. */
|
|
95
|
+
#dropOutbox() {
|
|
96
|
+
const items = readJson(this.f.outbox, null);
|
|
97
|
+
if (!Array.isArray(items)) return 0;
|
|
98
|
+
const counts = {};
|
|
99
|
+
for (const item of items) counts[item?.to ?? '?'] = (counts[item?.to ?? '?'] ?? 0) + 1;
|
|
100
|
+
rmSync(this.f.outbox, { force: true });
|
|
101
|
+
this.log(`dropped ${items.length} undelivered alarm mail(s) an earlier pack queued (superseded by the current reading; the alarms raised now are mailed again): ${Object.entries(counts).map(([to, n]) => `${n} to ${to}`).join(', ') || 'none'}`);
|
|
102
|
+
return items.length;
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
#mtime(file) { try { return statSync(file).mtimeMs; } catch { return null; } }
|
|
@@ -109,10 +124,8 @@ export class Probe {
|
|
|
109
124
|
const mail = compose({ machine: this.machine, transitions, reminders, raised: alarms });
|
|
110
125
|
if (mail) {
|
|
111
126
|
for (const a of alarms) this.state[a.key].lastMailAt = now;
|
|
112
|
-
for (const address of to) this.outbox.enqueue(address, `health-${this.machine}-${now}-${address.split(':').at(-1).slice(0, 12)}`, mail.subject, mail.body);
|
|
113
127
|
this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
|
|
114
128
|
}
|
|
115
|
-
const delivery = this.outbox.items.length ? await this.outbox.flush() : { pending: 0, error: this.outbox.error };
|
|
116
129
|
const snapshot = {
|
|
117
130
|
version: 1, machine: this.machine, platform: this.platform, at: new Date(now).toISOString(),
|
|
118
131
|
probe: { pid: process.pid, intervalSec: this.config.intervalSec, slowIntervalSec: this.config.slowIntervalSec, sampleMs: this.sampleMs, user: this.env.USER ?? null, root: this.reader.isRoot?.() ?? false, config: this.config.source },
|
|
@@ -120,10 +133,18 @@ export class Probe {
|
|
|
120
133
|
alarms,
|
|
121
134
|
transitions: transitions.map((t) => ({ key: t.key, from: t.from, to: t.to, summary: t.summary })),
|
|
122
135
|
maintainers: to,
|
|
123
|
-
mail:
|
|
136
|
+
mail: this.lastMail ?? null,
|
|
124
137
|
readings,
|
|
125
138
|
};
|
|
139
|
+
// The reading is written first; the mail is filed after it, and what the filing did is written beside it.
|
|
126
140
|
writeAtomic(this.f.latest, snapshot);
|
|
141
|
+
if (mail && to.length) {
|
|
142
|
+
const result = fileAlarm({ machine: this.machine, env: this.env, to, key: `health-${this.machine}-${now}`, subject: mail.subject, body: mail.body });
|
|
143
|
+
this.lastMail = { at: new Date(now).toISOString(), filed: result.filed.length, to: result.filed.map((f) => f.to), error: result.error };
|
|
144
|
+
if (result.error) this.log(result.error);
|
|
145
|
+
snapshot.mail = this.lastMail;
|
|
146
|
+
writeAtomic(this.f.latest, snapshot);
|
|
147
|
+
}
|
|
127
148
|
if (now - this.lastHistory >= 60_000) { this.#history(snapshot, now); this.lastHistory = now; }
|
|
128
149
|
return snapshot;
|
|
129
150
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|