@volter/supercode-health 0.0.0-stage → 0.1.1

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/mail.mjs ADDED
@@ -0,0 +1,64 @@
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';
6
+
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
+ });
41
+ }
42
+ }
43
+
44
+ const LEVEL = { warn: 'WARN', crit: 'CRIT', none: 'clear' };
45
+ function line(t) {
46
+ const d = t.detail;
47
+ const who = d?.session?.address ? `; session ${d.session.address}` : d?.session?.harness ? `; under ${d.session.harness} pid ${d.session.agentPid}` : d?.pid ? '; no agent session above it' : '';
48
+ const proc = d?.pid ? ` [pid ${d.pid}: ${d.command}${who}]` : d?.listener ? ` [listener pid ${d.listener.pid}: ${d.listener.command}${d.listener.session?.address ? `; session ${d.listener.session.address}` : ''}]` : '';
49
+ return `${LEVEL[t.to] ?? t.to}${t.from !== 'none' && t.to !== 'none' ? ` (was ${t.from})` : ''} ${t.key}: ${t.summary}${proc}`;
50
+ }
51
+
52
+ /** One mail for a cycle's transitions and the reminders due; null when there is nothing to say. */
53
+ export function compose({ machine, transitions, reminders, raised }) {
54
+ if (!transitions.length && !reminders.length) return null;
55
+ const up = transitions.filter((t) => t.to !== 'none');
56
+ const down = transitions.filter((t) => t.to === 'none');
57
+ const head = up.length ? `${up.length} raised` : reminders.length ? `${reminders.length} still raised` : `${down.length} cleared`;
58
+ const parts = [`[Machine health] ${machine}: ${head}.`];
59
+ if (up.length) parts.push('', ...up.map(line));
60
+ if (down.length) parts.push('', ...down.map(line));
61
+ if (reminders.length) parts.push('', 'Still raised:', ...reminders.map((r) => `${LEVEL[r.level]} ${r.key} since ${r.since}: ${r.summary}`));
62
+ parts.push('', `${raised.length} alarm${raised.length === 1 ? '' : 's'} raised now. 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.');
63
+ return { subject: `machine health ${machine}: ${head}`, body: parts.join('\n') };
64
+ }
package/lib/paths.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // Where the pack keeps its state: beside supercode's own home, so the machine daemon's doors find it
2
+ // with the same rule (`SUPERCODE_HOME`, else `$XDG_CONFIG_HOME/supercode`, else `~/.config/supercode`).
3
+ import { homedir, hostname } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ export function healthDir(env = process.env) {
7
+ if (env.SUPERCODE_HEALTH_DIR) return env.SUPERCODE_HEALTH_DIR;
8
+ const base = env.SUPERCODE_HOME || join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'supercode');
9
+ return join(base, 'health');
10
+ }
11
+
12
+ export const files = (dir) => ({
13
+ dir,
14
+ latest: join(dir, 'latest.json'),
15
+ history: join(dir, 'history.jsonl'),
16
+ config: join(dir, 'config.json'),
17
+ subscriptions: join(dir, 'subscriptions.json'),
18
+ outbox: join(dir, 'outbox.json'),
19
+ pid: join(dir, 'probe.pid'),
20
+ log: join(dir, 'probe.log'),
21
+ });
22
+
23
+ /** The machine's name as supercode's mail addresses spell it (`sc:<machine>:…`). */
24
+ export function machineName(name = hostname()) {
25
+ return String(name).split('.')[0].toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'localhost';
26
+ }
package/lib/probe.mjs ADDED
@@ -0,0 +1,168 @@
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.
3
+ // It decides nothing and changes nothing on the machine.
4
+ import { mkdirSync, openSync, closeSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync } from 'node:fs';
5
+ import { hostname } from 'node:os';
6
+ import { healthDir, files as healthFiles, machineName } from './paths.mjs';
7
+ import { DEFAULTS, merge, judge, raised } from './thresholds.mjs';
8
+ import { resolveFeatures } from './features.mjs';
9
+ import { Sampler } from './sample.mjs';
10
+ import { Outbox, compose } from './mail.mjs';
11
+ import { run } from './run.mjs';
12
+
13
+ export async function platformReader(platform = process.platform) {
14
+ if (platform === 'darwin') return import('./darwin.mjs');
15
+ if (platform === 'linux') return import('./linux.mjs');
16
+ if (platform === 'win32') return import('./win32.mjs');
17
+ return {};
18
+ }
19
+
20
+ const readJson = (file, fallback) => { try { return JSON.parse(readFileSync(file, 'utf8')); } catch { return fallback; } };
21
+ function writeAtomic(file, value) { const temp = `${file}.${process.pid}.tmp`; writeFileSync(temp, JSON.stringify(value, null, 2) + '\n', { mode: 0o600 }); renameSync(temp, file); }
22
+
23
+ export function loadConfig(file) {
24
+ const raw = readJson(file, {});
25
+ return { ...merge(DEFAULTS, raw), source: raw && Object.keys(raw).length ? file : null };
26
+ }
27
+
28
+ /** The addresses alarms go to: the config's maintainers and every door subscription. */
29
+ export function subscribers(f, config) {
30
+ const subs = readJson(f.subscriptions, { addresses: [] }).addresses ?? [];
31
+ return [...new Set([...(config.maintainers ?? []), ...subs])];
32
+ }
33
+
34
+ export function subscribe(dir, address, remove = false) {
35
+ if (!/^sc:[^:\s]+:[^:\s]+:\S+$/.test(address ?? '')) throw new Error('subscribe a full sc:<machine>:<harness>:<id> address');
36
+ const f = healthFiles(dir);
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
38
+ const current = readJson(f.subscriptions, { addresses: [] }).addresses ?? [];
39
+ const addresses = current.filter((a) => a !== address);
40
+ if (!remove) addresses.push(address);
41
+ writeAtomic(f.subscriptions, { addresses });
42
+ return { addresses };
43
+ }
44
+
45
+ export class Probe {
46
+ constructor({ env = process.env, machine, dir = healthDir(env), platform = process.platform } = {}) {
47
+ this.env = env;
48
+ this.platform = platform;
49
+ this.dir = dir;
50
+ this.f = healthFiles(dir);
51
+ this.machine = machineName(machine || env.SUPERCODE_HEALTH_MACHINE || hostname());
52
+ this.state = {};
53
+ this.lastHistory = 0;
54
+ this.sampleMs = null;
55
+ }
56
+
57
+ /** Claim the one probe per user. Returns false when a live probe holds the pidfile. */
58
+ lock() {
59
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
60
+ for (let attempt = 0; attempt < 2; attempt += 1) {
61
+ try { const fd = openSync(this.f.pid, 'wx', 0o600); writeFileSync(fd, `${process.pid}\n`); closeSync(fd); this.locked = true; return true; }
62
+ catch (error) {
63
+ if (error.code !== 'EEXIST') throw error;
64
+ const pid = Number(readFileSync(this.f.pid, 'utf8').trim());
65
+ let alive = false; try { process.kill(pid, 0); alive = pid !== process.pid; } catch (e) { alive = e.code === 'EPERM'; }
66
+ if (alive) { this.holder = pid; return false; }
67
+ rmSync(this.f.pid, { force: true });
68
+ }
69
+ }
70
+ return false;
71
+ }
72
+ unlock() { if (this.locked) { try { if (Number(readFileSync(this.f.pid, 'utf8')) === process.pid) rmSync(this.f.pid, { force: true }); } catch { /* gone */ } } }
73
+
74
+ async init() {
75
+ this.reader = await platformReader(this.platform);
76
+ this.config = loadConfig(this.f.config);
77
+ this.configMtime = this.#mtime(this.f.config);
78
+ 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
+ this.extras = {
81
+ rusage: this.reader.rusageAvailable ? await this.reader.rusageAvailable() : false,
82
+ supercode: (await run(this.env.SUPERCODE_BIN || 'supercode', ['--version'], { timeoutMs: 10_000, env: this.env })).ok,
83
+ };
84
+ const previous = readJson(this.f.latest, null);
85
+ // Alarms raised before a restart stay raised (and are not re-mailed); their windows restart honestly.
86
+ 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 };
88
+ }
89
+
90
+ #mtime(file) { try { return statSync(file).mtimeMs; } catch { return null; } }
91
+
92
+ features() {
93
+ const root = this.reader.isRoot?.() ?? false;
94
+ return resolveFeatures({ platform: this.platform, reader: this.reader, config: this.config, root, extras: this.extras });
95
+ }
96
+
97
+ async cycle(now = Date.now()) {
98
+ const mtime = this.#mtime(this.f.config);
99
+ if (mtime !== this.configMtime) { this.config = loadConfig(this.f.config); this.sampler.config = this.config; this.configMtime = mtime; }
100
+ const features = this.features();
101
+ const t0 = Date.now();
102
+ const { readings, observations, families, errors } = await this.sampler.sample(features, now);
103
+ this.sampleMs = Date.now() - t0;
104
+ const transitions = judge(this.state, observations, families, now);
105
+ const alarms = raised(this.state);
106
+ const repeatMs = this.config.repeatMin * 60_000;
107
+ const reminders = transitions.length ? [] : alarms.filter((a) => now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
108
+ const to = subscribers(this.f, this.config);
109
+ const mail = compose({ machine: this.machine, transitions, reminders, raised: alarms });
110
+ if (mail) {
111
+ 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
+ this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
114
+ }
115
+ const delivery = this.outbox.items.length ? await this.outbox.flush() : { pending: 0, error: this.outbox.error };
116
+ const snapshot = {
117
+ version: 1, machine: this.machine, platform: this.platform, at: new Date(now).toISOString(),
118
+ 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 },
119
+ features: features.map(({ id, on, barrier, reason, reads }) => ({ id, on, barrier, reason, reads, ...(errors[id] ? { error: errors[id] } : {}) })),
120
+ alarms,
121
+ transitions: transitions.map((t) => ({ key: t.key, from: t.from, to: t.to, summary: t.summary })),
122
+ maintainers: to,
123
+ mail: delivery,
124
+ readings,
125
+ };
126
+ writeAtomic(this.f.latest, snapshot);
127
+ if (now - this.lastHistory >= 60_000) { this.#history(snapshot, now); this.lastHistory = now; }
128
+ return snapshot;
129
+ }
130
+
131
+ #history(s, now) {
132
+ const r = s.readings;
133
+ const row = {
134
+ at: s.at, load1: r.load?.one, freePct: r.memory?.freePct, pressure: r.memory?.pressureLevel, swapoutsPerSec: r.memory?.swapoutsPerSec, compressionsPerSec: r.memory?.compressionsPerSec,
135
+ timeWait: r.tcp?.timeWait, ephemeralPct: r.tcp?.ephemeral?.[0]?.pct, renderers: r.renderers?.count,
136
+ disk: r.disk?.map((d) => [d.mount, d.freeGB]), topCpu: r.processes?.topCpu?.slice(0, 3).map((p) => [p.pid, p.cpuPct, p.command.slice(0, 60)]),
137
+ // Every process over 1 GB with its growth, so a process that is gone can still be judged leak or live work.
138
+ big: r.processes?.topMemory?.filter((p) => p.footprintMB >= 1024).map((p) => [p.pid, p.footprintMB, p.growthMBPerMin, p.command.slice(0, 100), p.session?.address ?? null]),
139
+ alarms: s.alarms.map((a) => `${a.level}:${a.key}`),
140
+ };
141
+ appendFileSync(this.f.history, JSON.stringify(row) + '\n', { mode: 0o600 });
142
+ // Trim to the configured window once an hour.
143
+ if (!this.trimAt || now - this.trimAt > 3_600_000) {
144
+ this.trimAt = now;
145
+ const keep = this.config.historyHours * 60;
146
+ const lines = readFileSync(this.f.history, 'utf8').split('\n').filter(Boolean);
147
+ if (lines.length > keep) writeFileSync(this.f.history, lines.slice(-keep).join('\n') + '\n', { mode: 0o600 });
148
+ }
149
+ }
150
+
151
+ log(text) {
152
+ try {
153
+ appendFileSync(this.f.log, `${new Date().toISOString()} ${text}\n`, { mode: 0o600 });
154
+ if (statSync(this.f.log).size > 5 * 1024 * 1024) { const lines = readFileSync(this.f.log, 'utf8').split('\n'); writeFileSync(this.f.log, lines.slice(-5000).join('\n')); }
155
+ } catch { /* logging never stops the probe */ }
156
+ }
157
+
158
+ async run({ signal } = {}) {
159
+ await this.init();
160
+ this.log(`probe ${process.pid} started on ${this.machine} (${this.platform}); features on: ${this.features().filter((f) => f.on).map((f) => f.id).join(', ')}`);
161
+ while (!signal?.aborted) {
162
+ const started = Date.now();
163
+ try { await this.cycle(started); } catch (error) { this.log(`cycle failed: ${error.stack ?? error.message}`); }
164
+ const wait = Math.max(1000, this.config.intervalSec * 1000 - (Date.now() - started));
165
+ await new Promise((resolve) => { const t = setTimeout(resolve, wait); signal?.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });
166
+ }
167
+ }
168
+ }
package/lib/run.mjs ADDED
@@ -0,0 +1,33 @@
1
+ // One bounded child process. Every instrument goes through here: a reading that cannot finish in its budget
2
+ // is an error the feature reports, never a hang of the probe.
3
+ import { execFile } from 'node:child_process';
4
+
5
+ export function run(file, args = [], { timeoutMs = 10_000, env, input, maxBuffer = 64 * 1024 * 1024 } = {}) {
6
+ return new Promise((resolve) => {
7
+ const child = execFile(file, args, { timeout: timeoutMs, env, maxBuffer, windowsHide: true, encoding: 'utf8', shell: process.platform === 'win32' }, (error, stdout, stderr) => {
8
+ if (error) resolve({ ok: false, stdout: stdout ?? '', stderr: stderr ?? '', error: error.killed ? `${file} timed out after ${timeoutMs} ms` : (error.code === 'ENOENT' ? `${file} not found` : (stderr || error.message).trim().slice(0, 300)) });
9
+ else resolve({ ok: true, stdout, stderr });
10
+ });
11
+ if (input !== undefined) child.stdin.end(input);
12
+ });
13
+ }
14
+
15
+ /** `[dd-]hh:mm:ss[.cc]` or `mm:ss[.cc]` (ps's TIME and ETIME) in seconds. */
16
+ export function clockSeconds(text) {
17
+ if (!text) return 0;
18
+ let days = 0, rest = String(text).trim();
19
+ const dash = rest.indexOf('-');
20
+ if (dash > 0) { days = Number(rest.slice(0, dash)); rest = rest.slice(dash + 1); }
21
+ const parts = rest.split(':').map(Number);
22
+ let seconds = 0;
23
+ for (const part of parts) seconds = seconds * 60 + (Number.isFinite(part) ? part : 0);
24
+ return days * 86400 + seconds;
25
+ }
26
+
27
+ /** top's MEM column (`1234M`, `2.1G`, `512K`, `96B`, with a trailing `+`/`-`) in bytes. */
28
+ export function topBytes(text) {
29
+ const match = /^([\d.]+)([BKMGT]?)[+-]?$/i.exec(String(text ?? '').trim());
30
+ if (!match) return null;
31
+ const unit = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 }[(match[2] || 'B').toUpperCase()];
32
+ return Math.round(Number(match[1]) * unit);
33
+ }
package/lib/rusage.py ADDED
@@ -0,0 +1,17 @@
1
+ # One pass of libproc's proc_pid_rusage over every pid this user may read (other users' fail without root).
2
+ # Prints "pid phys_footprint diskio_read diskio_written" per readable pid. About 50 ms for 1,100 pids.
3
+ import ctypes
4
+ lib = ctypes.CDLL('/usr/lib/libproc.dylib')
5
+ class RU(ctypes.Structure):
6
+ _fields_ = [('uuid', ctypes.c_uint8 * 16)] + [(n, ctypes.c_uint64) for n in (
7
+ 'user system pkg_idle int_wkups pageins wired resident phys proc_start proc_exit child_user child_system '
8
+ 'child_pkg_idle child_wkups child_pageins child_elapsed diskio_read diskio_written').split()]
9
+ n = lib.proc_listallpids(None, 0)
10
+ pids = (ctypes.c_int * (n + 64))()
11
+ n = lib.proc_listallpids(pids, ctypes.sizeof(pids))
12
+ out = []
13
+ for pid in pids[:n]:
14
+ r = RU()
15
+ if pid > 0 and lib.proc_pid_rusage(pid, 2, ctypes.byref(r)) == 0:
16
+ out.append('%d %d %d %d' % (pid, r.phys, r.diskio_read, r.diskio_written))
17
+ print('\n'.join(out))