@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 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' };
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 } 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,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: delivery,
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.2",
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",