@twinsearth/w2m-dsh-plugin 0.9.8 → 0.10.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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * Service health: measure, classify, and recommend — never act.
3
+ *
4
+ * ## Why this module only advises
5
+ *
6
+ * A component that both decides a server is unhealthy *and* changes that server is one bug away from
7
+ * deleting the thing it was protecting. Today gave a real example: the Linux host's root filesystem
8
+ * reached 100% because a process wrote 14.7 GB/day with no cap, and the recovery was `truncate` — **not**
9
+ * `rm` — because rsyslog still held the file descriptor open and removing it would have freed nothing
10
+ * while appearing to. A cleaner that had decided on its own to delete "the big log file" would have
11
+ * looked successful and changed nothing.
12
+ *
13
+ * So the split here is deliberate: this module produces measurements and named recommendations, and a
14
+ * separate caller with a human in the loop performs them. Every destructive idea is expressed as a
15
+ * *proposal with a reason*, and the ones that are safe to automate are marked as such and nothing more.
16
+ *
17
+ * ## Where the thresholds come from
18
+ *
19
+ * Not from a style guide. Each is tied to something that actually happened:
20
+ *
21
+ * * **Disk** — the incident above. `critical` at 90% used or under 1 GB free; `warn` at 80%. The host
22
+ * was at 100% with 268 MB free, which is the state this is for.
23
+ * * **Log growth** — 14.7 GB/day is the measured rate that caused it. Any single log growing faster
24
+ * than 1 GB/day is reported by name, because "the disk is filling" is not actionable and "this file
25
+ * is filling it" is.
26
+ * * **The 50-second task ceiling** — a machine whose load average is high enough to slow task
27
+ * execution is a machine that will start expiring tasks, so load is compared against CPU count
28
+ * rather than against an absolute number.
29
+ * * **Memory** — the relay host ran with 896 MB free of 16384 MB on MacMini during this session, which
30
+ * was fine; the threshold is set where the host starts swapping, not where it looks comfortable.
31
+ */
32
+ import { execFileSync } from 'node:child_process';
33
+ import { existsSync, readdirSync, statSync } from 'node:fs';
34
+ import { hostname, loadavg, cpus, freemem, totalmem, uptime, platform, arch, release } from 'node:os';
35
+
36
+ /** Severity, ordered. A caller compares `>=` rather than matching strings. */
37
+ export const SEVERITY = Object.freeze({ OK: 0, WARN: 1, CRITICAL: 2 });
38
+
39
+ /** Exit codes, so a fleet health check can report through a channel that carries only a status. */
40
+ export const HEALTH_EXIT = Object.freeze({ OK: 1, WARN: 2, CRITICAL: 3, UNREADABLE: 4 });
41
+
42
+ /** Bytes in a gigabyte, because every threshold below is about disk. */
43
+ const GB = 1024 ** 3;
44
+
45
+ /** The measured rate that filled the Linux host: 14.7 GB/day. A tenth of it is already too fast. */
46
+ export const LOG_GROWTH_WARN_BYTES_PER_DAY = 1 * GB;
47
+
48
+ /**
49
+ * Which paths are worth checking for growth, by platform.
50
+ *
51
+ * Deliberately short, and deliberately **not including a user's home directory**.
52
+ *
53
+ * A health check that walks a whole filesystem is itself a load source, and this list got that wrong on the
54
+ * first attempt: macOS was given `/Users`, and on a developer machine that is hundreds of thousands of files.
55
+ * The symptom appeared on a real machine as a health check that **exceeded the fleet's own ~50-second lease
56
+ * ceiling** and expired with no result at all — a monitoring tool timing out because it was too expensive to
57
+ * monitor. On the machine that wrote it, the same suite runs in 280 ms.
58
+ *
59
+ * So each entry is a place where an unbounded writer has actually been observed, which is what the 14.7
60
+ * GB/day incident was: two files in `/var/log`, not a general untidiness.
61
+ */
62
+ export const LOG_PATHS = Object.freeze({
63
+ linux: ['/var/log', '/var/lib/w2m', '/tmp'],
64
+ darwin: ['/var/log', '/Library/Logs', '/tmp'],
65
+ win32: ['C:\\Windows\\Logs', 'C:\\Windows\\Temp'],
66
+ });
67
+
68
+ /** Run a command and never throw; a missing tool is a state, not a failure. */
69
+ function run(file, args, timeout = 15000) {
70
+ try {
71
+ return { ok: true, out: String(execFileSync(file, args, { encoding: 'utf8', timeout, stdio: ['ignore', 'pipe', 'pipe'] })).trim() };
72
+ } catch (e) {
73
+ return { ok: false, code: e.status ?? e.code ?? null, out: String(e.stdout ?? '').trim(), err: String(e.stderr ?? e.message ?? '').trim().slice(0, 300) };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Free space for a path, in bytes, or `null` when it cannot be determined.
79
+ *
80
+ * `df -Pk` is the portable form: `-P` gives one line per filesystem in a fixed order, `-k` gives
81
+ * kilobytes regardless of the block size the platform would prefer. On Windows `df` does not exist, so
82
+ * the answer is `null` — and `null` is reported as "not measured" rather than as "plenty", because
83
+ * collapsing those two is how a full disk reads as an empty one.
84
+ */
85
+ export function diskFree(pathname) {
86
+ const df = run('df', ['-Pk', pathname]);
87
+ if (!df.ok) return null;
88
+ const lines = df.out.split('\n').filter(Boolean);
89
+ if (lines.length < 2) return null;
90
+ const parts = lines[lines.length - 1].split(/\s+/);
91
+ // Filesystem, 1K-blocks, Used, Available, Capacity, Mounted-on
92
+ const total = Number(parts[1]);
93
+ const used = Number(parts[2]);
94
+ const available = Number(parts[3]);
95
+ if (!Number.isFinite(total) || !Number.isFinite(available)) return null;
96
+ return {
97
+ path: pathname,
98
+ total_bytes: total * 1024,
99
+ used_bytes: Number.isFinite(used) ? used * 1024 : null,
100
+ free_bytes: available * 1024,
101
+ used_percent: total > 0 ? Math.round(((total - available) / total) * 1000) / 10 : null,
102
+ };
103
+ }
104
+
105
+ /** Directory size in bytes, bounded by a file count so a health check cannot become the load. */
106
+ export function directoryBytes(pathname, { maxEntries = 5000 } = {}) {
107
+ if (!existsSync(pathname)) return { path: pathname, bytes: 0, files: 0, truncated: false, missing: true };
108
+ let bytes = 0;
109
+ let files = 0;
110
+ let truncated = false;
111
+ const stack = [pathname];
112
+ while (stack.length > 0) {
113
+ const dir = stack.pop();
114
+ let entries;
115
+ try {
116
+ entries = readdirSync(dir, { withFileTypes: true });
117
+ } catch {
118
+ continue;
119
+ }
120
+ for (const entry of entries) {
121
+ if (files >= maxEntries) { truncated = true; break; }
122
+ const full = `${dir}/${entry.name}`;
123
+ try {
124
+ if (entry.isDirectory()) stack.push(full);
125
+ else if (entry.isFile()) { bytes += statSync(full).size; files += 1; }
126
+ } catch { /* unreadable entry: counted as neither */ }
127
+ }
128
+ if (truncated) break;
129
+ }
130
+ return { path: pathname, bytes, files, truncated, missing: false };
131
+ }
132
+
133
+ /** Largest files under a path, which is the actionable half of "the disk is filling". */
134
+ export function largestFiles(pathname, { limit = 5, maxEntries = 5000 } = {}) {
135
+ if (!existsSync(pathname)) return [];
136
+ const found = [];
137
+ let seen = 0;
138
+ const stack = [pathname];
139
+ while (stack.length > 0 && seen < maxEntries) {
140
+ const dir = stack.pop();
141
+ let entries;
142
+ try {
143
+ entries = readdirSync(dir, { withFileTypes: true });
144
+ } catch {
145
+ continue;
146
+ }
147
+ for (const entry of entries) {
148
+ if (seen >= maxEntries) break;
149
+ const full = `${dir}/${entry.name}`;
150
+ try {
151
+ if (entry.isDirectory()) stack.push(full);
152
+ else if (entry.isFile()) { seen += 1; found.push({ path: full, bytes: statSync(full).size }); }
153
+ } catch { /* skip */ }
154
+ }
155
+ }
156
+ return found.sort((a, b) => b.bytes - a.bytes).slice(0, limit);
157
+ }
158
+
159
+ /**
160
+ * Measure this machine.
161
+ *
162
+ * Every field is either a number or `null`, and `null` always means "not measured here" — never zero.
163
+ */
164
+ export function sample({ now = Date.now() } = {}) {
165
+ const load = loadavg();
166
+ const cpuCount = cpus().length;
167
+ const free = freemem();
168
+ const total = totalmem();
169
+
170
+ const disks = [];
171
+ for (const p of ['/', ...(platform() === 'darwin' ? ['/System/Volumes/Data'] : [])]) {
172
+ const d = diskFree(p);
173
+ if (d !== null) disks.push(d);
174
+ }
175
+
176
+ const logDirs = LOG_PATHS[platform()] ?? [];
177
+ const logs = logDirs.map((p) => {
178
+ const sized = directoryBytes(p);
179
+ return { ...sized, largest: largestFiles(p, { limit: 3 }) };
180
+ });
181
+
182
+ return {
183
+ at: new Date(now).toISOString(),
184
+ host: hostname(),
185
+ platform: platform(),
186
+ release: release(),
187
+ arch: arch(),
188
+ uptime_s: Math.round(uptime()),
189
+ cpu_count: cpuCount,
190
+ load_1m: load[0] ?? null,
191
+ load_5m: load[1] ?? null,
192
+ load_15m: load[2] ?? null,
193
+ mem_total_bytes: total,
194
+ mem_free_bytes: free,
195
+ mem_used_percent: total > 0 ? Math.round(((total - free) / total) * 1000) / 10 : null,
196
+ disks,
197
+ log_dirs: logs,
198
+ node: process.version,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Classify a sample.
204
+ *
205
+ * Returns findings rather than a single verdict, because "the disk is 91% full" and "this log grew a
206
+ * gigabyte today" need different responses and a single word hides which one you have. The overall
207
+ * severity is the worst finding, and it is reported *with* the findings rather than instead of them.
208
+ */
209
+ export function assess(s, options = {}) {
210
+ const diskWarnPercent = options.diskWarnPercent ?? 80;
211
+ const diskCriticalPercent = options.diskCriticalPercent ?? 90;
212
+ const diskCriticalFreeBytes = options.diskCriticalFreeBytes ?? 1 * GB;
213
+ const loadPerCpuWarn = options.loadPerCpuWarn ?? 1.5;
214
+ /**
215
+ * The critical multiple, named rather than derived.
216
+ *
217
+ * It was `loadPerCpuWarn * 2`, which reads as a rule and is a coincidence: it happens to equal 3.0 only
218
+ * while the warning threshold is 1.5, so changing one would silently move the other. Its own test
219
+ * asserted the documented 1.5/3.0 pair and failed, which is how this was found.
220
+ */
221
+ const loadPerCpuCritical = options.loadPerCpuCritical ?? 3;
222
+ const memWarnPercent = options.memWarnPercent ?? 90;
223
+ const growthWarn = options.logGrowthWarnBytesPerDay ?? LOG_GROWTH_WARN_BYTES_PER_DAY;
224
+
225
+ const findings = [];
226
+ const add = (severity, code, detail, action) => findings.push({ severity, code, detail, action });
227
+
228
+ if (s === null || typeof s !== 'object') {
229
+ return { severity: SEVERITY.CRITICAL, findings: [{ severity: SEVERITY.CRITICAL, code: 'NO_SAMPLE', detail: 'no sample was produced', action: null }] };
230
+ }
231
+
232
+ for (const d of s.disks ?? []) {
233
+ if (d.used_percent === null) continue;
234
+ if (d.used_percent >= diskCriticalPercent || d.free_bytes < diskCriticalFreeBytes) {
235
+ add(SEVERITY.CRITICAL, 'DISK_CRITICAL',
236
+ `${d.path} is ${d.used_percent}% used with ${(d.free_bytes / GB).toFixed(2)} GB free`,
237
+ `truncate the largest file under ${d.path} rather than removing it if a process still holds it open`);
238
+ } else if (d.used_percent >= diskWarnPercent) {
239
+ add(SEVERITY.WARN, 'DISK_WARN', `${d.path} is ${d.used_percent}% used`, 'check growth before it reaches 90%');
240
+ }
241
+ }
242
+
243
+ for (const dir of s.log_dirs ?? []) {
244
+ if (dir.missing === true) continue;
245
+ if (dir.truncated === true) {
246
+ add(SEVERITY.WARN, 'LOG_SCAN_TRUNCATED', `${dir.path} holds more files than the scan reads`, null);
247
+ }
248
+ for (const f of dir.largest ?? []) {
249
+ if (f.bytes >= growthWarn) {
250
+ add(SEVERITY.WARN, 'LOG_FILE_LARGE',
251
+ `${f.path} is ${(f.bytes / GB).toFixed(2)} GB`,
252
+ `confirm what writes it; today's incident was ${(14.7).toFixed(1)} GB/day from one daemon, and logrotate is the fix`);
253
+ }
254
+ }
255
+ }
256
+
257
+ if (Number.isFinite(s.load_1m) && Number.isFinite(s.cpu_count) && s.cpu_count > 0) {
258
+ const perCpu = s.load_1m / s.cpu_count;
259
+ if (perCpu >= loadPerCpuCritical) {
260
+ add(SEVERITY.CRITICAL, 'LOAD_CRITICAL', `load ${s.load_1m} over ${s.cpu_count} CPUs (${perCpu.toFixed(2)}/cpu)`,
261
+ 'a machine this loaded will start expiring tasks at the ~50s lease ceiling');
262
+ } else if (perCpu >= loadPerCpuWarn) {
263
+ add(SEVERITY.WARN, 'LOAD_WARN', `load ${s.load_1m} over ${s.cpu_count} CPUs (${perCpu.toFixed(2)}/cpu)`, null);
264
+ }
265
+ }
266
+
267
+ if (Number.isFinite(s.mem_used_percent) && s.mem_used_percent >= memWarnPercent) {
268
+ add(SEVERITY.WARN, 'MEM_WARN', `${s.mem_used_percent}% of memory used (${((s.mem_total_bytes - s.mem_free_bytes) / GB).toFixed(1)} GB of ${(s.mem_total_bytes / GB).toFixed(1)} GB)`,
269
+ 'identify the largest resident process before adding work');
270
+ }
271
+
272
+ const severity = findings.reduce((worst, f) => Math.max(worst, f.severity), SEVERITY.OK);
273
+ return { severity, findings };
274
+ }
275
+
276
+ /** The severity, as an exit code that a channel carrying only a status can transport. */
277
+ export function exitCodeFor(assessment) {
278
+ if (assessment?.severity === SEVERITY.CRITICAL) return HEALTH_EXIT.CRITICAL;
279
+ if (assessment?.severity === SEVERITY.WARN) return HEALTH_EXIT.WARN;
280
+ return HEALTH_EXIT.OK;
281
+ }
282
+
283
+ /** The name to print for a severity. */
284
+ export function severityName(severity) {
285
+ if (severity === SEVERITY.CRITICAL) return 'critical';
286
+ if (severity === SEVERITY.WARN) return 'warn';
287
+ return 'ok';
288
+ }
289
+
290
+ /**
291
+ * A one-line claim, for a fleet roll-up.
292
+ *
293
+ * Deliberately the *worst* finding rather than a count, because a status line that says "3 findings" makes
294
+ * the reader ask which one matters and a line that names it does not.
295
+ */
296
+ export function headline(s, assessment) {
297
+ const worst = (assessment?.findings ?? []).find((f) => f.severity === assessment.severity);
298
+ if (worst === undefined) return 'ok';
299
+ return `${severityName(worst.severity)}: ${worst.code} - ${worst.detail}`;
300
+ }
301
+
302
+ /**
303
+ * Which of this machine's own files are worth keeping forever.
304
+ *
305
+ * This is the "permanent storage" half, and it is expressed as a **classification** rather than as a copy
306
+ * action, because deciding to copy is a policy about where data may live and this module has no standing
307
+ * to make it. What it can say is which data is irreplaceable, and that is a fact:
308
+ *
309
+ * * `memory.jsonl` — the relay keeps hashes and never the text, so this file is the **only** durable copy
310
+ * of what a command printed. Deleting it deletes the fleet's ability to answer what it already tried.
311
+ * * `device.json` and `device.key` — identity and the key peers know this machine by. Losing the key
312
+ * makes every peer that holds the old one refuse this machine, with no visible cause.
313
+ * * `fleet-memory.md` — curated knowledge, written by hand, and therefore not reproducible from anything.
314
+ *
315
+ * Everything else is `disposable`: state that is rebuilt, spools that are in flight, and any log whose
316
+ * value is in its tail rather than its whole.
317
+ */
318
+ export const DATA_CLASS = Object.freeze({ IRREPLACEABLE: 'irreplaceable', REBUILDABLE: 'rebuildable', DISPOSABLE: 'disposable' });
319
+
320
+ /** Known filenames and their class, so the answer is the same on every machine. */
321
+ export const DATA_CLASSES = Object.freeze({
322
+ 'memory.jsonl': DATA_CLASS.IRREPLACEABLE,
323
+ 'memory.jsonl.1': DATA_CLASS.IRREPLACEABLE,
324
+ 'device.json': DATA_CLASS.IRREPLACEABLE,
325
+ 'device.key': DATA_CLASS.IRREPLACEABLE,
326
+ 'fleet-memory.md': DATA_CLASS.IRREPLACEABLE,
327
+ 'agent-state.json': DATA_CLASS.REBUILDABLE,
328
+ 'ledger.jsonl': DATA_CLASS.IRREPLACEABLE,
329
+ 'devices.json': DATA_CLASS.REBUILDABLE,
330
+ 'spool': DATA_CLASS.DISPOSABLE,
331
+ });
332
+
333
+ /** @param {string} name @returns {string} */
334
+ export function classifyData(name) {
335
+ const base = String(name).split(/[/\\]/).pop();
336
+ return DATA_CLASSES[base] ?? DATA_CLASS.DISPOSABLE;
337
+ }