@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 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>:<harness>:<id>` subscribes a session or board.
36
- Each raise, escalation and clear mails the subscribers. A still-raised alarm mails again
37
- every `repeatMin` (60). Mail comes from `sc:<machine>:operator:machine-health` through
38
- `supercode message remote-deliver`. Undelivered mail is kept and retried for a day.
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 a session or board address
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?.pending ? ` · ${s.mail.pending} undelivered: ${s.mail.error}` : ''}`);
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 public mailbox door (`supercode message remote-deliver`), from
2
- // `sc:<machine>:operator:machine-health`. Undelivered mail stays in the outbox and is retried each cycle,
3
- // as the machine-events outbox does, until it is a day old.
4
- import { spawn } from 'node:child_process';
5
- import { readFileSync, writeFileSync, renameSync } from 'node:fs';
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
- export class Outbox {
8
- constructor({ file, machine, env = process.env }) {
9
- Object.assign(this, { file, machine, env });
10
- try { this.items = JSON.parse(readFileSync(file, 'utf8')); } catch { this.items = []; }
11
- this.error = null;
12
- }
13
- save() { const temp = `${this.file}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(this.items) + '\n', { mode: 0o600 }); renameSync(temp, this.file); }
14
- enqueue(to, id, subject, body) { this.items.push({ to, id, subject, body, at: Date.now(), tries: 0 }); this.save(); }
15
- async flush() {
16
- for (const item of [...this.items]) {
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 mails the maintainers on a change.
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 { Outbox, compose } from './mail.mjs';
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
- this.outbox = new Outbox({ file: this.f.outbox, machine: this.machine, env: this.env });
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
- const mail = compose({ machine: this.machine, transitions, reminders, raised: alarms });
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: delivery,
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.2",
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",