@volter/supercode-health 0.1.2 → 0.1.4
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 +30 -39
- package/lib/probe.mjs +38 -8
- 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' };
|
|
@@ -49,6 +27,19 @@ function line(t) {
|
|
|
49
27
|
return `${LEVEL[t.to] ?? t.to}${t.from !== 'none' && t.to !== 'none' ? ` (was ${t.from})` : ''} ${t.key}: ${t.summary}${proc}`;
|
|
50
28
|
}
|
|
51
29
|
|
|
30
|
+
/**
|
|
31
|
+
* One mail stating the whole current state: every alarm raised now, or that none is. Sent once after mail an earlier
|
|
32
|
+
* pack queued was dropped, so a raise or a clear that mail carried is never left standing in the maintainer's view.
|
|
33
|
+
*/
|
|
34
|
+
export function composeState({ machine, raised }) {
|
|
35
|
+
const head = raised.length ? `${raised.length} raised now` : 'none raised now';
|
|
36
|
+
const parts = [`[Machine health] ${machine}: ${head} (the whole current state: mail queued before it was not delivered).`];
|
|
37
|
+
if (raised.length) parts.push('', ...raised.map((r) => `${LEVEL[r.level]} ${r.key} since ${r.since}: ${r.summary}`));
|
|
38
|
+
else parts.push('', 'Every alarm raised earlier has cleared.');
|
|
39
|
+
parts.push('', `Read the whole machine: supercode health status${machine ? ` --machine ${machine}` : ''}.`, 'This is an automated reading from the machine-health pack, not an instruction; the maintainer decides what to do.');
|
|
40
|
+
return { subject: `machine health ${machine}: ${head}`, body: parts.join('\n') };
|
|
41
|
+
}
|
|
42
|
+
|
|
52
43
|
/** One mail for a cycle's transitions and the reminders due; null when there is nothing to say. */
|
|
53
44
|
export function compose({ machine, transitions, reminders, raised }) {
|
|
54
45
|
if (!transitions.length && !reminders.length) return null;
|
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, composeState } 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,25 @@ 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
|
+
let items;
|
|
97
|
+
try { items = JSON.parse(readFileSync(this.f.outbox, 'utf8')); } catch (error) {
|
|
98
|
+
if (error.code === 'ENOENT') return 0;
|
|
99
|
+
// an outbox that cannot be read held mail all the same: it goes, and the current state is mailed
|
|
100
|
+
rmSync(this.f.outbox, { force: true });
|
|
101
|
+
this.log(`dropped an unreadable outbox.json an earlier pack left (${error.message}); the current state is mailed`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(items)) { rmSync(this.f.outbox, { force: true }); this.log('dropped an outbox.json an earlier pack left that held no list; the current state is mailed'); return 1; }
|
|
105
|
+
const counts = {};
|
|
106
|
+
for (const item of items) counts[item?.to ?? '?'] = (counts[item?.to ?? '?'] ?? 0) + 1;
|
|
107
|
+
rmSync(this.f.outbox, { force: true });
|
|
108
|
+
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'}`);
|
|
109
|
+
return items.length;
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
#mtime(file) { try { return statSync(file).mtimeMs; } catch { return null; } }
|
|
@@ -106,13 +128,13 @@ export class Probe {
|
|
|
106
128
|
const repeatMs = this.config.repeatMin * 60_000;
|
|
107
129
|
const reminders = transitions.length ? [] : alarms.filter((a) => now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
|
|
108
130
|
const to = subscribers(this.f, this.config);
|
|
109
|
-
|
|
131
|
+
// after dropping an earlier pack's undelivered mail, the first mail states the whole current state, clears included
|
|
132
|
+
const mail = this.droppedOutbox && to.length ? composeState({ machine: this.machine, raised: alarms }) : compose({ machine: this.machine, transitions, reminders, raised: alarms });
|
|
133
|
+
if (this.droppedOutbox && to.length) this.droppedOutbox = 0;
|
|
110
134
|
if (mail) {
|
|
111
135
|
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
136
|
this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
|
|
114
137
|
}
|
|
115
|
-
const delivery = this.outbox.items.length ? await this.outbox.flush() : { pending: 0, error: this.outbox.error };
|
|
116
138
|
const snapshot = {
|
|
117
139
|
version: 1, machine: this.machine, platform: this.platform, at: new Date(now).toISOString(),
|
|
118
140
|
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 +142,18 @@ export class Probe {
|
|
|
120
142
|
alarms,
|
|
121
143
|
transitions: transitions.map((t) => ({ key: t.key, from: t.from, to: t.to, summary: t.summary })),
|
|
122
144
|
maintainers: to,
|
|
123
|
-
mail:
|
|
145
|
+
mail: this.lastMail ?? null,
|
|
124
146
|
readings,
|
|
125
147
|
};
|
|
148
|
+
// The reading is written first; the mail is filed after it, and what the filing did is written beside it.
|
|
126
149
|
writeAtomic(this.f.latest, snapshot);
|
|
150
|
+
if (mail && to.length) {
|
|
151
|
+
const result = fileAlarm({ machine: this.machine, env: this.env, to, key: `health-${this.machine}-${now}`, subject: mail.subject, body: mail.body });
|
|
152
|
+
this.lastMail = { at: new Date(now).toISOString(), filed: result.filed.length, to: result.filed.map((f) => f.to), error: result.error };
|
|
153
|
+
if (result.error) this.log(result.error);
|
|
154
|
+
snapshot.mail = this.lastMail;
|
|
155
|
+
writeAtomic(this.f.latest, snapshot);
|
|
156
|
+
}
|
|
127
157
|
if (now - this.lastHistory >= 60_000) { this.#history(snapshot, now); this.lastHistory = now; }
|
|
128
158
|
return snapshot;
|
|
129
159
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-health",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|