@volter/supercode-health 0.1.14 → 0.1.15

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/drift.mjs CHANGED
@@ -71,7 +71,18 @@ export async function staleSupercode({ rows = null } = {}) {
71
71
  } else if (Number.isNaN(started) || installed <= started + 1000) continue; // ps says a start to the second
72
72
  stale.push({ pid: Number(pid), command: command.slice(0, 200), file, startedAt: new Date(started).toISOString(), installedAt: new Date(installed).toISOString() });
73
73
  }
74
- return stale.sort((a, b) => b.installedAt.localeCompare(a.installedAt));
74
+ return moved(stale).sort((a, b) => b.installedAt.localeCompare(a.installedAt));
75
+ }
76
+
77
+ /** Less the processes that answer through the release on disk: an MCP server's pump says so in `<home>/run/mcp/<pid>`
78
+ * once a child of the release an install put there answers for it (lib/mcp.mjs), at or after that install. */
79
+ function moved(stale) {
80
+ const home = process.env.SUPERCODE_HOME || (process.env.XDG_CONFIG_HOME ? join(process.env.XDG_CONFIG_HOME, 'supercode') : join(homedir(), '.config', 'supercode'));
81
+ return stale.filter((row) => {
82
+ let said = null;
83
+ try { said = JSON.parse(readFileSync(join(home, 'run', 'mcp', String(row.pid)), 'utf8')); } catch { return true; }
84
+ return !(said?.pid === row.pid && Date.parse(said.at) >= Date.parse(row.installedAt));
85
+ });
75
86
  }
76
87
 
77
88
  /**
package/lib/probe.mjs CHANGED
@@ -51,6 +51,9 @@ export class Probe {
51
51
  this.f = healthFiles(dir);
52
52
  this.machine = machineName(machine || env.SUPERCODE_HEALTH_MACHINE || hostname());
53
53
  this.state = {};
54
+ // what the last mail said of each alarm (key → its level, summary, detail): a mail says what changed since, never each
55
+ // flip in between (t_be880329: disk and memory flapping mailed the maintainer ~1,200 times a day on yuerans)
56
+ this.said = new Map(); this.changesMailAt = 0; this.raisedLastAt = new Map(); // key → when it was last seen raised
54
57
  this.unknowns = {}; this.unknownMailAt = {}; // unknownMailAt: a kind of unknown (unknownKind: its key's prefix) → last mailed
55
58
  this.lastHistory = 0;
56
59
  this.sampleMs = null;
@@ -99,6 +102,7 @@ export class Probe {
99
102
  // A raised alarm whose reading was unknown keeps its unknown since across the restart, as its unknowns entry does.
100
103
  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,
101
104
  ...(a.unknown?.since ? { unread: a.unknown.why, unreadAt: Date.parse(a.unknown.since) } : {}) };
105
+ for (const a of (previous?.alarms ?? [])) this.said.set(a.key, { level: a.level, summary: a.summary, detail: a.detail ?? null });
102
106
  }
103
107
 
104
108
  /** Remove an earlier pack's outbox; answers how many mails it held, logged by recipient. */
@@ -138,7 +142,26 @@ export class Probe {
138
142
  const repeatMs = this.config.repeatMin * 60_000;
139
143
  // A raised alarm is reminded each repeatMin after it was last said: in a mail of its own transition or reminder, never
140
144
  // pushed back by a mail about something else.
141
- const raisedNow = new Set(transitions.filter((t) => t.to !== 'none').map((t) => t.key));
145
+ // What changed since the last mail said it (net of every raise and clear between): said at most once each quietMin,
146
+ // a new crit at once, and with any mail going anyway (a reminder, an unknown due). A key that raised and cleared
147
+ // between two mails is never said.
148
+ const quietMs = (this.config.quietMin ?? 15) * 60_000;
149
+ const levels = new Map(alarms.map((a) => [a.key, a]));
150
+ const lastTransition = new Map(transitions.map((t) => [t.key, t]));
151
+ const net = [];
152
+ for (const a of alarms) {
153
+ const was = this.said.get(a.key)?.level ?? 'none';
154
+ if (was !== a.level) net.push({ key: a.key, from: was, to: a.level, value: a.value, unit: a.unit ?? null, summary: a.summary, detail: a.detail ?? null });
155
+ }
156
+ for (const a of alarms) this.raisedLastAt.set(a.key, now);
157
+ for (const [key, was] of this.said) {
158
+ if (levels.has(key)) continue;
159
+ // a clear is said once it has held a whole quiet window: an alarm that flaps is said raised once, never each clear
160
+ if (now - (this.raisedLastAt.get(key) ?? 0) < quietMs) continue;
161
+ const t = lastTransition.get(key);
162
+ net.push({ key, from: was.level, to: 'none', value: null, summary: t?.to === 'none' ? t.summary : `${was.summary} — cleared`, detail: t?.detail ?? was.detail });
163
+ }
164
+ const raisedNow = new Set(net.filter((t) => t.to !== 'none').map((t) => t.key));
142
165
  const reminders = alarms.filter((a) => !raisedNow.has(a.key) && now - (this.state[a.key].lastMailAt ?? 0) >= repeatMs);
143
166
  // A reading unknown for unknownMin is the maintainers' to know (raised or not), and again each repeatMin until read.
144
167
  // Timed per kind (a key's prefix; a family's whole key): a kind is due once its earliest key has been unknown
@@ -159,14 +182,25 @@ export class Probe {
159
182
  const to = subscribers(this.f, this.config);
160
183
  // after dropping an earlier pack's undelivered mail, the first mail states the whole current state, clears included
161
184
  const composedState = Boolean(this.droppedOutbox && to.length);
162
- const mail = composedState ? composeState({ machine: this.machine, raised: alarms }) : compose({ machine: this.machine, transitions, reminders, raised: alarms, unknowns: unknownsDue });
185
+ const changesDue = net.length > 0 && (now - this.changesMailAt >= quietMs || net.some((t) => t.to === 'crit' && t.from !== 'crit') || reminders.length > 0 || unknownsDue.length > 0);
186
+ const changes = changesDue ? net : [];
187
+ const mail = composedState ? composeState({ machine: this.machine, raised: alarms }) : compose({ machine: this.machine, transitions: changes, reminders: reminders.filter((a) => !changes.some((t) => t.key === a.key)), raised: alarms, unknowns: unknownsDue });
163
188
  if (composedState) this.droppedOutbox = 0;
164
189
  if (mail) {
165
190
  // the kinds of unknown this mail said (the whole-state mail says none: they stay due)
166
191
  if (!composedState) for (const [kind] of dueKinds) this.unknownMailAt[kind] = now;
167
192
  // only the alarms this mail said: raised by it, or reminded (the whole-state mail says every one)
168
- const said = composedState ? alarms : alarms.filter((a) => raisedNow.has(a.key) || reminders.includes(a));
193
+ const said = composedState ? alarms : alarms.filter((a) => (changes.length && raisedNow.has(a.key)) || reminders.includes(a));
169
194
  for (const a of said) this.state[a.key].lastMailAt = now;
195
+ // the state this mail said is what the next mail is measured from: the whole state, or the last one said with this
196
+ // mail's changes applied. A clear still in its hold was not said, so its alarm stays said raised until a mail
197
+ // says the clear (#1268 review P1: replacing it with the alarms raised now lost that clear for good)
198
+ if (composedState) this.said = new Map(alarms.map((a) => [a.key, { level: a.level, summary: a.summary, detail: a.detail ?? null }]));
199
+ else for (const t of changes) {
200
+ if (t.to === 'none') this.said.delete(t.key);
201
+ else this.said.set(t.key, { level: t.to, summary: t.summary, detail: t.detail ?? null });
202
+ }
203
+ if (composedState || changes.length) this.changesMailAt = now;
170
204
  this.log(`${mail.subject}${to.length ? '' : ' (no maintainer subscribed)'}\n${mail.body}`);
171
205
  }
172
206
  const snapshot = {
@@ -5,6 +5,7 @@ export const DEFAULTS = {
5
5
  slowIntervalSec: 60, // disk space, services, the connector, tabs
6
6
  footprintTopEverySec: 300, // macOS: every user's largest footprints through top (about 2 s of top per run)
7
7
  repeatMin: 60, // a raised alarm mails again this often (netdata's repeat cap)
8
+ quietMin: 15, // what changed is mailed at most this often (a new crit at once): a flapping alarm is no stream
8
9
  unknownMin: 15, // a reading unknown this long (raised or not) is mailed to the maintainers, and again each repeatMin
9
10
  historyHours: 24,
10
11
  daemons: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-health",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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",