@camstack/system 1.2.218 → 1.2.219

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.
Files changed (93) hide show
  1. package/dist/addon-runner.js +2 -2
  2. package/dist/addon-runner.mjs +2 -2
  3. package/dist/addon-utils.js +2 -2
  4. package/dist/addon-utils.mjs +2 -2
  5. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
  6. package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
  9. package/dist/builtins/alerts/alerts.addon.js +1 -1
  10. package/dist/builtins/alerts/alerts.addon.mjs +1 -1
  11. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
  12. package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
  13. package/dist/builtins/console-logging/index.js +1 -1
  14. package/dist/builtins/console-logging/index.mjs +1 -1
  15. package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
  16. package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +2 -2
  18. package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
  19. package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
  20. package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
  21. package/dist/builtins/hub-forwarder/index.js +1 -1
  22. package/dist/builtins/hub-forwarder/index.mjs +1 -1
  23. package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
  24. package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +1 -1
  26. package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
  27. package/dist/builtins/local-network/local-network.addon.js +2 -2
  28. package/dist/builtins/local-network/local-network.addon.mjs +2 -2
  29. package/dist/builtins/loki-logging/index.js +1 -1
  30. package/dist/builtins/loki-logging/index.mjs +1 -1
  31. package/dist/builtins/native-metrics/gpu-probe.d.ts +18 -0
  32. package/dist/builtins/native-metrics/native-metrics-provider.d.ts +1 -1
  33. package/dist/builtins/native-metrics/native-metrics.addon.d.ts +26 -3
  34. package/dist/builtins/native-metrics/native-metrics.addon.js +1320 -1093
  35. package/dist/builtins/native-metrics/native-metrics.addon.mjs +1320 -1096
  36. package/dist/builtins/native-metrics/proc-process-table.d.ts +25 -0
  37. package/dist/builtins/platform-probe/index.js +3 -3
  38. package/dist/builtins/platform-probe/index.mjs +3 -3
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -1
  42. package/dist/builtins/snapshot/index.mjs +1 -1
  43. package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
  44. package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
  45. package/dist/builtins/sqlite-storage/sqlite-pragmas.d.ts +23 -0
  46. package/dist/builtins/sqlite-storage/sqlite-settings-backend.d.ts +1 -1
  47. package/dist/builtins/sqlite-storage/sqlite-settings.addon.d.ts +1 -0
  48. package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
  49. package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
  50. package/dist/builtins/sqlite-storage/wal-checkpoint-policy.d.ts +115 -0
  51. package/dist/builtins/sqlite-storage/wal-checkpoint-worker.d.ts +1 -0
  52. package/dist/builtins/sqlite-storage/wal-checkpointer.d.ts +70 -0
  53. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
  54. package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
  55. package/dist/builtins/system-config/system-config.addon.js +1 -1
  56. package/dist/builtins/system-config/system-config.addon.mjs +1 -1
  57. package/dist/builtins/winston-logging/index.js +1 -1
  58. package/dist/builtins/winston-logging/index.mjs +1 -1
  59. package/dist/{custom-action-registry-CsONUlhC.mjs → custom-action-registry-B6gOtUWA.mjs} +1 -1
  60. package/dist/{custom-action-registry-BDwM5qAK.js → custom-action-registry-DKWhaWL1.js} +1 -1
  61. package/dist/{dist-BOlMCOeT.js → dist-B17W9ngu.js} +2 -2
  62. package/dist/{dist-DigmGsQS.mjs → dist-Cyz5_i7z.mjs} +2 -2
  63. package/dist/index.d.ts +6 -1
  64. package/dist/index.js +31 -36
  65. package/dist/index.mjs +23 -38
  66. package/dist/kernel/heap-watch.d.ts +24 -1
  67. package/dist/kernel/moleculer/mesh-queue-report.d.ts +51 -0
  68. package/dist/kernel/outbound-queue-watch.d.ts +94 -0
  69. package/dist/kernel/socket-plane-report.d.ts +75 -4
  70. package/dist/kernel/transport/cap-routing-hints.d.ts +21 -0
  71. package/dist/kernel/transport/child-cap-protocol.d.ts +28 -2
  72. package/dist/kernel/transport/frame-codec.d.ts +49 -0
  73. package/dist/kernel/transport/local-child-client.d.ts +7 -0
  74. package/dist/kernel/transport/local-child-registry.d.ts +36 -1
  75. package/dist/kernel/transport/local-transport.d.ts +45 -2
  76. package/dist/kernel/transport/socket-channel.d.ts +19 -1
  77. package/dist/{manifest-system-deps-D--wyMD9.mjs → manifest-system-deps-C7dihwWT.mjs} +1210 -682
  78. package/dist/{manifest-system-deps-Bo92lg5u.js → manifest-system-deps-g_nK0xsF.js} +1252 -664
  79. package/dist/process/proc-stat.d.ts +75 -0
  80. package/dist/process/resource-monitor.d.ts +9 -1
  81. package/dist/{resource-monitor-CdnzxBLP.js → resource-monitor-LVE1BoLs.js} +42 -23
  82. package/dist/{resource-monitor-BWmQ5i-o.mjs → resource-monitor-Vp0bPV3U.mjs} +42 -22
  83. package/dist/{retired-settings-keys-vK_U-CG2.mjs → retired-settings-keys-BCYUrdsA.mjs} +1 -1
  84. package/dist/{retired-settings-keys-xEAlYnv7.js → retired-settings-keys-CjvB7Etx.js} +1 -1
  85. package/dist/wal-checkpoint-policy-CAcg63o-.mjs +92 -0
  86. package/dist/wal-checkpoint-policy-CCJZngds.js +127 -0
  87. package/dist/wal-checkpoint-worker.js +138 -0
  88. package/dist/wal-checkpoint-worker.mjs +137 -0
  89. package/package.json +1 -1
  90. package/dist/{event-loop-stall-monitor-DM9OAzy8.js → event-loop-stall-monitor-CAsHdMwP.js} +1 -1
  91. package/dist/{event-loop-stall-monitor-B3CFd9tM.mjs → event-loop-stall-monitor-GPrVvx14.mjs} +1 -1
  92. package/dist/{tls-DOTmtLCW.mjs → tls-2xbgg78V.mjs} +2 -2
  93. package/dist/{tls-BxQlomxd.js → tls-BFs4PzoW.js} +2 -2
@@ -1,10 +1,11 @@
1
- import { Jt as errMsg, Ot as reducePoints, Yt as BaseAddon, cn as createEvent, kt as resolveBucketMs, rt as foldSnapshotByFunction, xn as EventCategory, xt as metricsProviderCapability } from "../../dist-DigmGsQS.mjs";
2
- import { readFile, readdir } from "node:fs/promises";
3
- import { execFile, execFileSync } from "node:child_process";
4
- import { promisify } from "node:util";
1
+ import { Jt as errMsg, Ot as reducePoints, Yt as BaseAddon, cn as createEvent, kt as resolveBucketMs, rt as foldSnapshotByFunction, xn as EventCategory, xt as metricsProviderCapability } from "../../dist-Cyz5_i7z.mjs";
2
+ import { access, readFile, readdir } from "node:fs/promises";
5
3
  import * as fs from "node:fs";
6
- import { readFileSync } from "node:fs";
4
+ import { constants, readFileSync } from "node:fs";
5
+ import { execFile, execFileSync } from "node:child_process";
7
6
  import * as os from "node:os";
7
+ import { platform } from "node:os";
8
+ import { promisify } from "node:util";
8
9
  //#region src/builtins/native-metrics/cgroup-memory.ts
9
10
  /** `readFileSync`, with every failure flattened to `null`. */
10
11
  var nodeCgroupFileReader = { read(path) {
@@ -1086,1118 +1087,1376 @@ var ContainerMemoryStore = class {
1086
1087
  }
1087
1088
  };
1088
1089
  //#endregion
1089
- //#region src/builtins/native-metrics/native-metrics-provider.ts
1090
- var IS_DARWIN = os.platform() === "darwin";
1091
- var IS_LINUX = os.platform() === "linux";
1092
- var NativeMetricsProvider = class {
1093
- id = "native";
1094
- cachedSnapshot = null;
1095
- samplingTimer = null;
1096
- macMemTimer = null;
1097
- prevCpu;
1098
- diagnostics = null;
1099
- consecutiveSampleErrors = 0;
1100
- constructor() {
1101
- this.prevCpu = aggregateCpuTimes();
1090
+ //#region src/builtins/native-metrics/load-series-store.ts
1091
+ /**
1092
+ * @durable class=audit owner=metrics-native
1093
+ * write="one row per process per sample, appended in ONE transaction per
1094
+ * sample (`settingsStore.insertMany`) by the HUB's `native-metrics`, off its
1095
+ * subscription to `metrics.node-processes-snapshot`. Emission is
1096
+ * fixed-cadence, so the write rate is deterministic: nodes x processes /
1097
+ * cadence, 7.6 rows/s on the live fleet at the 10 s default. Only a sample
1098
+ * the in-memory ring ACCEPTED is written, which is what makes the append
1099
+ * idempotent without a unique key."
1100
+ * retention="TWO bounds, swept together at most once a minute. By AGE, the
1101
+ * operator's `loadSeriesRetentionHours` (default 6, the intention); by ROWS,
1102
+ * DEFAULT_MAX_ROWS (the guarantee, which bites regardless of the age knob).
1103
+ * Both prune oldest-first through a bounded page — never a full scan — and
1104
+ * the row cap logs when it bites. Losing the table loses the cold window
1105
+ * only: the ring keeps serving the last hour and the charts keep drawing."
1106
+ */
1107
+ var LOAD_SERIES_COLLECTION = "metrics:node-load-samples";
1108
+ /**
1109
+ * Hard ceiling on the table, in ROWS.
1110
+ *
1111
+ * The guarantee behind the retention knob. Sized against the shipped default
1112
+ * and against the worst configuration the knob can express:
1113
+ *
1114
+ * | cadence | retention | rows (3 nodes, 76 processes) |
1115
+ * | --- | --- | --- |
1116
+ * | 10 s | 6 h (default) | 164 160 |
1117
+ * | 5 s | 6 h | 328 320 |
1118
+ * | 5 s | 72 h | 3 939 840 — capped here |
1119
+ *
1120
+ * 500 000 rows x the measured 87 B = **41.5 MiB**, roughly 3x what the shipped
1121
+ * default needs and far below the 2 M rows a three-day retention would reach.
1122
+ * It is deliberately NOT a knob: the point of a guarantee is that it is not
1123
+ * subject to the same hand that raised the intention. This is the discipline
1124
+ * the in-memory ring already adopted (`DEFAULT_MAX_TOTAL_PROCESS_ROWS`) and
1125
+ * the one the RTP pre-roll rings still lack — a maximum per instance and no
1126
+ * budget at all for the whole, which reached 898 MB across 47 rings
1127
+ * (`docs/architecture/load-ledger.md` #5).
1128
+ */
1129
+ var DEFAULT_MAX_ROWS = 5e5;
1130
+ var LOAD_SERIES_COLUMNS = [
1131
+ (
1132
+ /** `INTEGER PRIMARY KEY` = SQLite rowid alias: the key IS the row's address,
1133
+ * so it costs no separate index and no stored string. */
1134
+ {
1135
+ name: "id",
1136
+ type: "INTEGER",
1137
+ primaryKey: true,
1138
+ notNull: true
1139
+ }),
1140
+ {
1141
+ name: "nodeId",
1142
+ type: "TEXT",
1143
+ notNull: true
1144
+ },
1145
+ (
1146
+ /** The EMITTING node's timestamp for the sample this row belongs to. Every
1147
+ * row of one sample shares it — that is what makes a sample reassemblable. */
1148
+ {
1149
+ name: "atMs",
1150
+ type: "INTEGER",
1151
+ notNull: true
1152
+ }),
1153
+ {
1154
+ name: "pid",
1155
+ type: "INTEGER",
1156
+ notNull: true
1157
+ },
1158
+ (
1159
+ /** `NULL` for a process no addon owns — `root` and `system` both. */
1160
+ {
1161
+ name: "addonId",
1162
+ type: "TEXT"
1163
+ }),
1164
+ {
1165
+ name: "classification",
1166
+ type: "TEXT",
1167
+ notNull: true
1168
+ },
1169
+ (
1170
+ /** `ps pcpu` x 10. A LIFETIME average, not a rate — see `NodeProcess`. */
1171
+ {
1172
+ name: "cpuDeci",
1173
+ type: "INTEGER",
1174
+ notNull: true
1175
+ }),
1176
+ {
1177
+ name: "rssMib",
1178
+ type: "INTEGER",
1179
+ notNull: true
1180
+ },
1181
+ (
1182
+ /** Instantaneous main-thread CPU% x 10. `NULL` = UNKNOWN, never zero. */
1183
+ {
1184
+ name: "cpuMainDeci",
1185
+ type: "INTEGER"
1186
+ }),
1187
+ (
1188
+ /** Instantaneous V8-helper-pool CPU% x 10. `NULL` = UNKNOWN, never zero. */
1189
+ {
1190
+ name: "cpuGcDeci",
1191
+ type: "INTEGER"
1192
+ })
1193
+ ];
1194
+ var LOAD_SERIES_INDEXES = [(
1195
+ /**
1196
+ * The ONE index, and it serves both jobs.
1197
+ *
1198
+ * Reads are always "this node, newer than T" — `nodeId` leads so the
1199
+ * equality is a range scan and `atMs` supplies the order without a sort.
1200
+ * Prunes are always "this node, oldest first" — the same index, walked from
1201
+ * the other end. A second index on `atMs` alone was measured and rejected:
1202
+ * it cost 23 B/row (196.0 → 172.8 with the two column drops) to serve a
1203
+ * cross-node prune that a per-node loop over three nodes already serves.
1204
+ */
1205
+ {
1206
+ name: "idx_load_samples_node_at",
1207
+ columns: ["nodeId", "atMs"]
1208
+ })];
1209
+ var BYTES_PER_MIB = 1048576;
1210
+ /** Tenths → percent. `null` stays `null`: unknown is not zero. */
1211
+ function fromDeci(value) {
1212
+ return value === null ? null : Math.round(value) / 10;
1213
+ }
1214
+ /** Percent → tenths, lossless because both producers already round to tenths. */
1215
+ function toDeci(value) {
1216
+ return value === null ? null : Math.round(value * 10);
1217
+ }
1218
+ /** Storage row → the shape a reader gets. The ONE decode. */
1219
+ function rowToProcess(row) {
1220
+ return {
1221
+ pid: row.pid,
1222
+ addonId: row.addonId,
1223
+ classification: row.classification,
1224
+ cpuPercent: fromDeci(row.cpuDeci) ?? 0,
1225
+ memoryRssBytes: row.rssMib * BYTES_PER_MIB,
1226
+ cpuMainPercent: fromDeci(row.cpuMainDeci),
1227
+ cpuGcPercent: fromDeci(row.cpuGcDeci)
1228
+ };
1229
+ }
1230
+ /** The shape a reader gets → the storage row. The ONE encode. */
1231
+ function processToRow(nodeId, atMs, process) {
1232
+ return {
1233
+ nodeId,
1234
+ atMs,
1235
+ pid: process.pid,
1236
+ addonId: process.addonId,
1237
+ classification: process.classification,
1238
+ cpuDeci: toDeci(process.cpuPercent) ?? 0,
1239
+ rssMib: Math.round(process.memoryRssBytes / BYTES_PER_MIB),
1240
+ cpuMainDeci: toDeci(process.cpuMainPercent),
1241
+ cpuGcDeci: toDeci(process.cpuGcPercent)
1242
+ };
1243
+ }
1244
+ /** Group flat rows into samples, oldest-first. A sample is one `atMs`. */
1245
+ function rowsToSamples(rows) {
1246
+ const byAt = /* @__PURE__ */ new Map();
1247
+ for (const row of rows) {
1248
+ const held = byAt.get(row.atMs);
1249
+ if (held === void 0) byAt.set(row.atMs, [rowToProcess(row)]);
1250
+ else held.push(rowToProcess(row));
1102
1251
  }
1103
- /**
1104
- * Optional diagnostics sink for sampling errors. The first failure and
1105
- * every Nth failure thereafter are reported so a permanently-broken
1106
- * collector doesn't spam logs but is still observable.
1107
- */
1108
- setDiagnostics(diag) {
1109
- this.diagnostics = diag;
1252
+ return [...byAt.entries()].toSorted((a, b) => a[0] - b[0]).map(([atMs, processes]) => ({
1253
+ atMs,
1254
+ processes
1255
+ }));
1256
+ }
1257
+ /**
1258
+ * The cold tier. Append-only, bounded, and never the authority on anything the
1259
+ * hot ring can answer.
1260
+ *
1261
+ * Every method is best-effort and says so in the log rather than throwing at
1262
+ * the emitter: a storage hiccup must cost a gap in a chart, never a dropped
1263
+ * snapshot on the bus.
1264
+ */
1265
+ var LoadSeriesStore = class {
1266
+ declared = false;
1267
+ lastPruneAtMs = 0;
1268
+ store;
1269
+ logger;
1270
+ nowFn;
1271
+ prunePageRows;
1272
+ pruneIntervalMs;
1273
+ constructor(deps) {
1274
+ this.store = deps.store;
1275
+ this.logger = deps.logger;
1276
+ this.nowFn = deps.now ?? (() => Date.now());
1277
+ this.prunePageRows = deps.prunePageRows ?? 2e4;
1278
+ this.pruneIntervalMs = deps.pruneIntervalMs ?? 6e4;
1110
1279
  }
1111
- startSampling(intervalMs) {
1112
- if (IS_DARWIN) {
1113
- sampleMacMemory(this);
1114
- this.macMemTimer = setInterval(() => sampleMacMemory(this), 5e3);
1115
- if (this.macMemTimer.unref) this.macMemTimer.unref();
1280
+ /** Idempotently declare the collection. `false` when the store refused. */
1281
+ async declare() {
1282
+ if (this.declared) return true;
1283
+ try {
1284
+ await this.store.declareCollection.mutate({
1285
+ collection: LOAD_SERIES_COLLECTION,
1286
+ columns: [...LOAD_SERIES_COLUMNS],
1287
+ indexes: LOAD_SERIES_INDEXES.map((i) => ({
1288
+ name: i.name,
1289
+ columns: [...i.columns]
1290
+ }))
1291
+ });
1292
+ this.declared = true;
1293
+ return true;
1294
+ } catch (err) {
1295
+ this.logger.warn("load series declareCollection failed — nothing will be retained on disk", { meta: {
1296
+ collection: LOAD_SERIES_COLLECTION,
1297
+ error: errMsg(err)
1298
+ } });
1299
+ return false;
1116
1300
  }
1117
- this.collectSnapshot().then((s) => {
1118
- this.cachedSnapshot = s;
1119
- this.consecutiveSampleErrors = 0;
1120
- }).catch((err) => this.reportSampleError(err));
1121
- this.samplingTimer = setInterval(() => {
1122
- this.collectSnapshot().then((s) => {
1123
- this.cachedSnapshot = s;
1124
- this.consecutiveSampleErrors = 0;
1125
- }).catch((err) => this.reportSampleError(err));
1126
- }, intervalMs);
1127
- if (this.samplingTimer.unref) this.samplingTimer.unref();
1128
1301
  }
1129
- reportSampleError(err) {
1130
- this.consecutiveSampleErrors++;
1131
- if (!this.diagnostics) return;
1132
- if (!(this.consecutiveSampleErrors === 1 || this.consecutiveSampleErrors % 60 === 0)) return;
1133
- this.diagnostics.warn("metrics-provider sample failed", {
1134
- consecutiveErrors: this.consecutiveSampleErrors,
1135
- error: err instanceof Error ? err.message : String(err)
1136
- });
1137
- }
1138
- stopSampling() {
1139
- if (this.samplingTimer) {
1140
- clearInterval(this.samplingTimer);
1141
- this.samplingTimer = null;
1142
- }
1143
- if (this.macMemTimer) {
1144
- clearInterval(this.macMemTimer);
1145
- this.macMemTimer = null;
1302
+ /**
1303
+ * Append ONE sample — every process row of it — in ONE transaction.
1304
+ *
1305
+ * Never a write per row. `insertMany` exists for exactly this: at the 10 s
1306
+ * default the fleet produces 7.6 rows/s, and 7.6 separate commits per second
1307
+ * on the connection that also serves every cluster-wide configuration read
1308
+ * is a constant load nobody asked for.
1309
+ */
1310
+ async append(nodeId, atMs, processes) {
1311
+ if (processes.length === 0) return 0;
1312
+ if (!await this.declare()) return 0;
1313
+ const records = processes.map((p) => ({ data: { ...processToRow(nodeId, atMs, p) } }));
1314
+ try {
1315
+ const { inserted } = await this.store.insertMany.mutate({
1316
+ collection: LOAD_SERIES_COLLECTION,
1317
+ records
1318
+ });
1319
+ return inserted;
1320
+ } catch (err) {
1321
+ this.logger.warn("load series sample not retained — this interval will be missing", { meta: {
1322
+ nodeId,
1323
+ atMs,
1324
+ rows: processes.length,
1325
+ error: errMsg(err)
1326
+ } });
1327
+ return 0;
1146
1328
  }
1147
1329
  }
1148
- async collectSnapshot() {
1149
- const cpu = this.sampleCpu();
1150
- const memory = this.getMemoryInfo();
1151
- const processInfo = getProcessResources();
1152
- const [gpu, network, disk, cpuTemp] = await Promise.all([
1153
- getGpuInfoInternal().catch(() => null),
1154
- takeNetworkIoSnapshot(),
1155
- takeDiskIoSnapshot(),
1156
- getCpuTemperatureInternal().catch(() => null)
1157
- ]);
1158
- const snapshot = {
1159
- cpu,
1160
- memory,
1161
- gpu,
1162
- network,
1163
- disk,
1164
- pressure: {
1165
- cpu: getPressure("cpu"),
1166
- memory: getPressure("memory"),
1167
- io: getPressure("io")
1168
- },
1169
- process: processInfo,
1170
- cpuTemperature: cpuTemp,
1171
- timestampMs: Date.now()
1172
- };
1173
- this.cachedSnapshot = snapshot;
1174
- return snapshot;
1175
- }
1176
- async getCached() {
1177
- return this.cachedSnapshot;
1330
+ /**
1331
+ * Read one node's cold samples, oldest-first.
1332
+ *
1333
+ * `sinceMs` is EXCLUSIVE, matching the ring, so a caller passing the newest
1334
+ * timestamp it holds gets only what it is missing. `limitRows` bounds the
1335
+ * read in ROWS (not samples) because rows are what the query costs.
1336
+ */
1337
+ async read(nodeId, sinceMs, limitRows) {
1338
+ if (!await this.declare()) return [];
1339
+ try {
1340
+ const records = await this.store.query.query({
1341
+ collection: LOAD_SERIES_COLLECTION,
1342
+ filter: {
1343
+ where: { nodeId },
1344
+ whereBetween: { atMs: [sinceMs + 1, Number.MAX_SAFE_INTEGER] },
1345
+ orderBy: {
1346
+ field: "atMs",
1347
+ direction: "asc"
1348
+ },
1349
+ limit: limitRows
1350
+ }
1351
+ });
1352
+ const rows = [];
1353
+ for (const record of records) {
1354
+ const row = recordToRow(record.data);
1355
+ if (row !== null) rows.push(row);
1356
+ }
1357
+ return rowsToSamples(rows);
1358
+ } catch (err) {
1359
+ this.logger.warn("load series cold read failed — answering from the hot window only", { meta: {
1360
+ nodeId,
1361
+ sinceMs,
1362
+ error: errMsg(err)
1363
+ } });
1364
+ return [];
1365
+ }
1178
1366
  }
1179
- async getCurrent() {
1180
- const s = this.cachedSnapshot ?? await this.collectSnapshot();
1181
- const mb = (bytes) => Math.round(bytes / 1024 / 1024);
1367
+ /**
1368
+ * Enforce BOTH bounds, oldest-first, through a bounded page each.
1369
+ *
1370
+ * Rate-limited to {@link pruneIntervalMs}: a bound is not a deadline, and the
1371
+ * append path must not pay a sweep on every sample.
1372
+ */
1373
+ async prune(nodeIds, retention, force = false) {
1374
+ const now = this.nowFn();
1375
+ if (!force && now - this.lastPruneAtMs < this.pruneIntervalMs) return null;
1376
+ this.lastPruneAtMs = now;
1377
+ if (!await this.declare()) return null;
1378
+ let deletedByAge = 0;
1379
+ let deletedByCap = 0;
1380
+ let rowsExamined = 0;
1381
+ const ageCutoff = now - retention.retentionHours * 36e5;
1382
+ for (const nodeId of nodeIds) {
1383
+ const outcome = await this.pruneNodeToCutoff(nodeId, ageCutoff);
1384
+ deletedByAge += outcome.deleted;
1385
+ rowsExamined += outcome.examined;
1386
+ }
1387
+ const total = await this.count();
1388
+ const excess = total === null ? 0 : total - retention.maxRows;
1389
+ if (excess > 0) {
1390
+ const outcome = await this.pruneOldestRows(nodeIds, excess);
1391
+ deletedByCap = outcome.deleted;
1392
+ rowsExamined += outcome.examined;
1393
+ this.logger.warn("load series ROW CAP bit — evicting the oldest samples", { meta: {
1394
+ collection: LOAD_SERIES_COLLECTION,
1395
+ rows: total,
1396
+ cap: retention.maxRows,
1397
+ over: excess,
1398
+ deleted: outcome.deleted,
1399
+ retentionHours: retention.retentionHours,
1400
+ hint: "lower the retention or the sampling cadence — the cap is the guarantee, not the intention"
1401
+ } });
1402
+ }
1182
1403
  return {
1183
- cpuPercent: s.cpu.total,
1184
- memoryPercent: s.memory.percent,
1185
- memoryUsedMB: mb(s.memory.usedBytes),
1186
- memoryTotalMB: mb(s.memory.totalBytes),
1187
- temperature: s.cpuTemperature ?? void 0,
1188
- gpuPercent: s.gpu?.utilization,
1189
- gpuMemoryPercent: s.gpu && s.gpu.memoryTotalBytes > 0 ? Math.round(s.gpu.memoryUsedBytes / s.gpu.memoryTotalBytes * 1e3) / 10 : void 0
1404
+ deletedByAge,
1405
+ deletedByCap,
1406
+ rowsExamined,
1407
+ capBit: excess > 0
1190
1408
  };
1191
1409
  }
1192
- async getDiskSpace(input) {
1193
- return getDiskSpaceInternal(input.dirPath);
1194
- }
1195
- async getGpuInfo() {
1196
- return getGpuInfoInternal();
1197
- }
1198
- async getCpuTemperature() {
1199
- return getCpuTemperatureInternal();
1200
- }
1201
- async getProcessStats(input) {
1202
- const { getPidStats } = await import("../../resource-monitor-BWmQ5i-o.mjs").then((n) => n.r);
1203
- const raw = await getPidStats(input.pids);
1204
- const result = [];
1205
- for (const [pid, s] of raw) result.push({
1206
- pid,
1207
- cpu: s.cpu,
1208
- memory: s.memory,
1209
- privateBytes: s.privateBytes,
1210
- sharedBytes: s.sharedBytes
1211
- });
1212
- return result;
1410
+ /** Total rows, or `null` when the store could not answer. */
1411
+ async count() {
1412
+ if (!await this.declare()) return null;
1413
+ try {
1414
+ return await this.store.count.query({ collection: LOAD_SERIES_COLLECTION });
1415
+ } catch (err) {
1416
+ this.logger.warn("load series count failed — the row cap is not enforced this pass", { meta: { error: errMsg(err) } });
1417
+ return null;
1418
+ }
1213
1419
  }
1214
- sampleCpu() {
1215
- const curr = aggregateCpuTimes();
1216
- const prev = this.prevCpu;
1217
- this.prevCpu = curr;
1218
- const dUser = curr.user - prev.user;
1219
- const dNice = curr.nice - prev.nice;
1220
- const dSys = curr.sys - prev.sys;
1221
- const dIrq = curr.irq - prev.irq;
1222
- const dIdle = curr.idle - prev.idle;
1223
- const dTotal = dUser + dNice + dSys + dIrq + dIdle;
1224
- const pct = (v) => dTotal === 0 ? 0 : Math.round(v / dTotal * 1e3) / 10;
1225
- return {
1226
- total: pct(dUser + dNice + dSys + dIrq),
1227
- user: pct(dUser),
1228
- system: pct(dSys),
1229
- irq: pct(dIrq),
1230
- nice: pct(dNice),
1231
- loadAvg: os.loadavg(),
1232
- cores: os.cpus().length
1233
- };
1420
+ /**
1421
+ * Delete this node's rows older than `cutoff`, at most one page's worth.
1422
+ *
1423
+ * The page is the whole point. `deleteWhere({ atMs: [0, cutoff] })` on its
1424
+ * own is one statement but an UNBOUNDED one — a first pass after a retention
1425
+ * change would delete millions of rows inside a single stalling transaction.
1426
+ * So the page is read first (keys only, ordered by the index), its last
1427
+ * `atMs` becomes the EFFECTIVE cutoff, and the delete is bounded by it.
1428
+ */
1429
+ async pruneNodeToCutoff(nodeId, cutoff) {
1430
+ try {
1431
+ const page = await this.store.query.query({
1432
+ collection: LOAD_SERIES_COLLECTION,
1433
+ filter: {
1434
+ where: { nodeId },
1435
+ whereBetween: { atMs: [0, cutoff] },
1436
+ orderBy: {
1437
+ field: "atMs",
1438
+ direction: "asc"
1439
+ },
1440
+ limit: this.prunePageRows
1441
+ },
1442
+ columns: ["atMs"]
1443
+ });
1444
+ if (page.length === 0) return {
1445
+ deleted: 0,
1446
+ examined: 0
1447
+ };
1448
+ const effectiveCutoff = Number(page.at(-1)?.data["atMs"]);
1449
+ if (!Number.isFinite(effectiveCutoff)) return {
1450
+ deleted: 0,
1451
+ examined: page.length
1452
+ };
1453
+ const { deleted } = await this.store.deleteWhere.mutate({
1454
+ collection: LOAD_SERIES_COLLECTION,
1455
+ filter: {
1456
+ where: { nodeId },
1457
+ whereBetween: { atMs: [0, effectiveCutoff] }
1458
+ }
1459
+ });
1460
+ return {
1461
+ deleted,
1462
+ examined: page.length
1463
+ };
1464
+ } catch (err) {
1465
+ this.logger.warn("load series age prune failed — the table keeps growing this pass", { meta: {
1466
+ nodeId,
1467
+ cutoff,
1468
+ error: errMsg(err)
1469
+ } });
1470
+ return {
1471
+ deleted: 0,
1472
+ examined: 0
1473
+ };
1474
+ }
1234
1475
  }
1235
- /** Cached macOS memory (set by sampleMacMemory timer). */
1236
- _cachedMacMem = null;
1237
- getMemoryInfo() {
1238
- const total = os.totalmem();
1239
- if (total === 0) return {
1240
- percent: 0,
1241
- totalBytes: 0,
1242
- usedBytes: 0,
1243
- availableBytes: 0,
1244
- swapUsedBytes: 0,
1245
- swapTotalBytes: 0
1246
- };
1247
- if (IS_DARWIN && this._cachedMacMem !== null) return this._cachedMacMem;
1248
- const free = os.freemem();
1249
- const used = total - free;
1250
- let swapUsedBytes = 0;
1251
- let swapTotalBytes = 0;
1252
- if (IS_LINUX) try {
1253
- const meminfo = fs.readFileSync("/proc/meminfo", "utf-8");
1254
- const swapTotal = parseInt(meminfo.match(/SwapTotal:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
1255
- const swapFree = parseInt(meminfo.match(/SwapFree:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
1256
- swapTotalBytes = swapTotal;
1257
- swapUsedBytes = swapTotal - swapFree;
1258
- } catch {}
1476
+ /**
1477
+ * Drop the oldest rows across the known nodes until `excess` is covered.
1478
+ *
1479
+ * Same bounded-page technique, walked per node so the one index serves it.
1480
+ * The node holding the oldest rows pays first, which is also the node
1481
+ * producing the pressure when a runaway process count is the cause.
1482
+ */
1483
+ async pruneOldestRows(nodeIds, excess) {
1484
+ let remaining = Math.min(excess, this.prunePageRows);
1485
+ let deleted = 0;
1486
+ let examined = 0;
1487
+ for (const nodeId of nodeIds) {
1488
+ if (remaining <= 0) break;
1489
+ try {
1490
+ const page = await this.store.query.query({
1491
+ collection: LOAD_SERIES_COLLECTION,
1492
+ filter: {
1493
+ where: { nodeId },
1494
+ orderBy: {
1495
+ field: "atMs",
1496
+ direction: "asc"
1497
+ },
1498
+ limit: remaining
1499
+ },
1500
+ columns: ["atMs"]
1501
+ });
1502
+ examined += page.length;
1503
+ if (page.length === 0) continue;
1504
+ const cutoff = Number(page.at(-1)?.data["atMs"]);
1505
+ if (!Number.isFinite(cutoff)) continue;
1506
+ const result = await this.store.deleteWhere.mutate({
1507
+ collection: LOAD_SERIES_COLLECTION,
1508
+ filter: {
1509
+ where: { nodeId },
1510
+ whereBetween: { atMs: [0, cutoff] }
1511
+ }
1512
+ });
1513
+ deleted += result.deleted;
1514
+ remaining -= result.deleted;
1515
+ } catch (err) {
1516
+ this.logger.warn("load series cap prune failed — the cap is not enforced this pass", { meta: {
1517
+ nodeId,
1518
+ error: errMsg(err)
1519
+ } });
1520
+ }
1521
+ }
1259
1522
  return {
1260
- percent: Math.round((total - free) / total * 1e3) / 10,
1261
- totalBytes: total,
1262
- usedBytes: used,
1263
- availableBytes: free,
1264
- swapUsedBytes,
1265
- swapTotalBytes
1523
+ deleted,
1524
+ examined
1266
1525
  };
1267
1526
  }
1268
1527
  };
1269
- function aggregateCpuTimes() {
1270
- const cpus = os.cpus();
1271
- let user = 0, nice = 0, sys = 0, irq = 0, idle = 0;
1272
- for (const cpu of cpus) {
1273
- user += cpu.times.user;
1274
- nice += cpu.times.nice;
1275
- sys += cpu.times.sys;
1276
- irq += cpu.times.irq;
1277
- idle += cpu.times.idle;
1278
- }
1279
- return {
1280
- user,
1281
- nice,
1282
- sys,
1283
- irq,
1284
- idle
1285
- };
1286
- }
1287
- function sampleMacMemory(provider) {
1288
- try {
1289
- const output = execFileSync("vm_stat", {
1290
- timeout: 2e3,
1291
- encoding: "utf-8"
1292
- });
1293
- const pageSize = 16384;
1294
- const parse = (label) => {
1295
- const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
1296
- return match ? parseInt(match[1], 10) * pageSize : 0;
1297
- };
1298
- const free = parse("Pages free");
1299
- const inactive = parse("Pages inactive");
1300
- const purgeable = parse("Pages purgeable");
1301
- const speculative = parse("Pages speculative");
1302
- const available = free + inactive + purgeable + speculative;
1303
- const total = os.totalmem();
1304
- const used = total - available;
1305
- let swapUsedBytes = 0;
1306
- let swapTotalBytes = 0;
1307
- try {
1308
- const swapOut = execFileSync("sysctl", ["-n", "vm.swapusage"], {
1309
- timeout: 2e3,
1310
- encoding: "utf-8"
1311
- });
1312
- const totalMatch = swapOut.match(/total\s*=\s*([\d.]+)M/);
1313
- const usedMatch = swapOut.match(/used\s*=\s*([\d.]+)M/);
1314
- if (totalMatch) swapTotalBytes = parseFloat(totalMatch[1]) * 1024 * 1024;
1315
- if (usedMatch) swapUsedBytes = parseFloat(usedMatch[1]) * 1024 * 1024;
1316
- } catch {}
1317
- provider._cachedMacMem = {
1318
- percent: Math.round(used / total * 1e3) / 10,
1319
- totalBytes: total,
1320
- usedBytes: used,
1321
- availableBytes: available,
1322
- swapUsedBytes,
1323
- swapTotalBytes
1324
- };
1325
- } catch {
1326
- provider._cachedMacMem = null;
1327
- }
1328
- }
1329
- function getDiskSpaceInternal(dirPath) {
1330
- return new Promise((resolve, reject) => {
1331
- fs.statfs(dirPath, (err, stats) => {
1332
- if (err) return reject(err);
1333
- const totalBytes = stats.blocks * stats.bsize;
1334
- const availableBytes = stats.bavail * stats.bsize;
1335
- const usedBytes = totalBytes - stats.bfree * stats.bsize;
1336
- resolve({
1337
- path: dirPath,
1338
- totalBytes,
1339
- usedBytes,
1340
- availableBytes,
1341
- percent: totalBytes > 0 ? Math.round(usedBytes / totalBytes * 1e3) / 10 : 0
1342
- });
1343
- });
1344
- });
1345
- }
1346
- async function takeDiskIoSnapshot() {
1347
- const now = Date.now();
1348
- if (IS_LINUX) try {
1349
- const data = fs.readFileSync("/proc/diskstats", "utf-8");
1350
- let readOps = 0, readBytes = 0, writeOps = 0, writeBytes = 0;
1351
- for (const line of data.split("\n")) {
1352
- const parts = line.trim().split(/\s+/);
1353
- if (parts.length < 14) continue;
1354
- const devName = parts[2];
1355
- if (/\d+$/.test(devName) && !/^nvme\d+n\d+$/.test(devName)) continue;
1356
- readOps += parseInt(parts[3], 10) || 0;
1357
- readBytes += (parseInt(parts[5], 10) || 0) * 512;
1358
- writeOps += parseInt(parts[7], 10) || 0;
1359
- writeBytes += (parseInt(parts[9], 10) || 0) * 512;
1360
- }
1361
- return {
1362
- readBytes,
1363
- writeBytes,
1364
- readOps,
1365
- writeOps,
1366
- timestampMs: now
1367
- };
1368
- } catch {}
1369
- return {
1370
- readBytes: 0,
1371
- writeBytes: 0,
1372
- readOps: 0,
1373
- writeOps: 0,
1374
- timestampMs: now
1528
+ /** Structural validation on read. A malformed row is skipped, never charted. */
1529
+ function recordToRow(data) {
1530
+ const nodeId = data["nodeId"];
1531
+ const classification = data["classification"];
1532
+ if (typeof nodeId !== "string" || typeof classification !== "string") return null;
1533
+ const atMs = Number(data["atMs"]);
1534
+ const pid = Number(data["pid"]);
1535
+ const cpuDeci = Number(data["cpuDeci"]);
1536
+ const rssMib = Number(data["rssMib"]);
1537
+ if (![
1538
+ atMs,
1539
+ pid,
1540
+ cpuDeci,
1541
+ rssMib
1542
+ ].every((n) => Number.isFinite(n))) return null;
1543
+ const rawAddon = data["addonId"];
1544
+ const optional = (raw) => {
1545
+ if (raw === null || raw === void 0) return null;
1546
+ const n = Number(raw);
1547
+ return Number.isFinite(n) ? n : null;
1375
1548
  };
1376
- }
1377
- async function takeNetworkIoSnapshot() {
1378
- const now = Date.now();
1379
- if (IS_LINUX) try {
1380
- const data = fs.readFileSync("/proc/net/dev", "utf-8");
1381
- let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
1382
- for (const line of data.split("\n")) {
1383
- const match = line.match(/^\s*(\w+):\s+(.+)$/);
1384
- if (!match) continue;
1385
- if (match[1] === "lo") continue;
1386
- const cols = match[2].trim().split(/\s+/);
1387
- rxBytes += parseInt(cols[0], 10) || 0;
1388
- rxPackets += parseInt(cols[1], 10) || 0;
1389
- rxErrors += parseInt(cols[2], 10) || 0;
1390
- txBytes += parseInt(cols[8], 10) || 0;
1391
- txPackets += parseInt(cols[9], 10) || 0;
1392
- txErrors += parseInt(cols[10], 10) || 0;
1393
- }
1394
- return {
1395
- rxBytes,
1396
- txBytes,
1397
- rxPackets,
1398
- txPackets,
1399
- rxErrors,
1400
- txErrors,
1401
- timestampMs: now
1402
- };
1403
- } catch {}
1404
- if (IS_DARWIN) try {
1405
- const stdout = await execAsync("netstat", ["-ib"]);
1406
- let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
1407
- for (const line of stdout.split("\n")) {
1408
- const parts = line.trim().split(/\s+/);
1409
- if (parts.length < 11) continue;
1410
- if (parts[0] === "lo0" || parts[0] === "Name") continue;
1411
- if (!parts[2]?.startsWith("Link#")) continue;
1412
- rxPackets += parseInt(parts[4], 10) || 0;
1413
- rxErrors += parseInt(parts[5], 10) || 0;
1414
- rxBytes += parseInt(parts[6], 10) || 0;
1415
- txPackets += parseInt(parts[7], 10) || 0;
1416
- txErrors += parseInt(parts[8], 10) || 0;
1417
- txBytes += parseInt(parts[9], 10) || 0;
1418
- }
1419
- return {
1420
- rxBytes,
1421
- txBytes,
1422
- rxPackets,
1423
- txPackets,
1424
- rxErrors,
1425
- txErrors,
1426
- timestampMs: now
1427
- };
1428
- } catch {}
1429
1549
  return {
1430
- rxBytes: 0,
1431
- txBytes: 0,
1432
- rxPackets: 0,
1433
- txPackets: 0,
1434
- rxErrors: 0,
1435
- txErrors: 0,
1436
- timestampMs: now
1550
+ nodeId,
1551
+ atMs,
1552
+ pid,
1553
+ addonId: typeof rawAddon === "string" ? rawAddon : null,
1554
+ classification,
1555
+ cpuDeci,
1556
+ rssMib,
1557
+ cpuMainDeci: optional(data["cpuMainDeci"]),
1558
+ cpuGcDeci: optional(data["cpuGcDeci"])
1437
1559
  };
1438
1560
  }
1439
- async function getCpuTemperatureInternal() {
1440
- if (IS_LINUX) for (const p of ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]) try {
1441
- const raw = fs.readFileSync(p, "utf-8").trim();
1442
- const millideg = parseInt(raw, 10);
1443
- if (!isNaN(millideg)) return Math.round(millideg / 100) / 10;
1444
- } catch {
1445
- continue;
1446
- }
1447
- if (IS_DARWIN) try {
1448
- const match = (await execAsync("sudo", [
1449
- "-n",
1450
- "powermetrics",
1451
- "--samplers",
1452
- "smc",
1453
- "-i",
1454
- "1",
1455
- "-n",
1456
- "1"
1457
- ], 3e3)).match(/CPU die temperature:\s+([\d.]+)\s*C/);
1458
- if (match) return Math.round(parseFloat(match[1]) * 10) / 10;
1459
- } catch {}
1460
- return null;
1461
- }
1462
- async function getGpuInfoInternal() {
1463
- if (IS_DARWIN) try {
1464
- const utilMatch = (await execAsync("ioreg", [
1465
- "-r",
1466
- "-d",
1467
- "1",
1468
- "-c",
1469
- "IOAccelerator"
1470
- ])).match(/"GPU Core Utilization"\s*=\s*(\d+)/);
1471
- const util = utilMatch ? parseInt(utilMatch[1], 10) : 0;
1472
- const utilPct = util > 1e3 ? Math.round(util / 1e7) : util;
1473
- return {
1474
- utilization: Math.min(100, utilPct),
1475
- model: "Apple Silicon GPU",
1476
- memoryUsedBytes: 0,
1477
- memoryTotalBytes: 0,
1478
- temperature: null
1479
- };
1480
- } catch {}
1481
- if (IS_LINUX) try {
1482
- const parts = (await execAsync("nvidia-smi", ["--query-gpu=utilization.gpu,name,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits"])).trim().split(",").map((s) => s.trim());
1483
- if (parts.length >= 5) return {
1484
- utilization: parseInt(parts[0], 10) || 0,
1485
- model: parts[1] ?? "Unknown GPU",
1486
- memoryUsedBytes: (parseInt(parts[2], 10) || 0) * 1024 * 1024,
1487
- memoryTotalBytes: (parseInt(parts[3], 10) || 0) * 1024 * 1024,
1488
- temperature: parseInt(parts[4], 10) || null
1489
- };
1490
- } catch {}
1491
- return null;
1492
- }
1493
- function getProcessResources() {
1494
- let openFds = 0;
1495
- let threadCount = 0;
1496
- if (IS_LINUX) {
1497
- try {
1498
- openFds = fs.readdirSync(`/proc/${process.pid}/fd`).length;
1499
- } catch {}
1500
- try {
1501
- const match = fs.readFileSync(`/proc/${process.pid}/status`, "utf-8").match(/Threads:\s+(\d+)/);
1502
- if (match) threadCount = parseInt(match[1], 10);
1503
- } catch {}
1561
+ //#endregion
1562
+ //#region src/builtins/native-metrics/load-series-config.ts
1563
+ /**
1564
+ * The two knobs of the durable load series, and the arithmetic that makes them
1565
+ * honest.
1566
+ *
1567
+ * ## Where they live, and why there
1568
+ *
1569
+ * In `native-metrics`'s OWN global settings document — the one that already
1570
+ * exists and already carries `samplingIntervalMs`. Fields on a document, never
1571
+ * a method per knob: `system.getLoggingSettings` / `setLoggingSettings` set
1572
+ * that precedent and it holds here.
1573
+ *
1574
+ * They are NOT in the logging document, and that is a decision rather than an
1575
+ * omission. That document is about levels and diagnostic windows; a metrics
1576
+ * cadence and a table's retention are neither. Two documents that both claim a
1577
+ * knob is how this repo has already shipped a switch nobody read.
1578
+ *
1579
+ * They are cluster-wide, not per-node. Every node emits on the same cadence or
1580
+ * the fleet's series cannot be laid over each other, and the table is single
1581
+ * and hub-resident, so a per-node retention would be a promise nothing could
1582
+ * keep.
1583
+ *
1584
+ * ## Refused, not clamped
1585
+ *
1586
+ * A cadence outside 5-60 s is REFUSED. Clamping would silently store 10 when
1587
+ * the operator typed 2 and then report 10 back as though it had been accepted
1588
+ * — the failure mode where a knob and its readback agree on a value nobody
1589
+ * chose. The schema advertises the bounds; this refuses anything else.
1590
+ */
1591
+ /** Why a value was refused — the message an operator is shown verbatim. */
1592
+ var LoadSeriesConfigError = class extends Error {
1593
+ constructor(message) {
1594
+ super(message);
1595
+ this.name = "LoadSeriesConfigError";
1596
+ }
1597
+ };
1598
+ function requireInteger(value, field) {
1599
+ const n = typeof value === "number" ? value : Number(value);
1600
+ if (!Number.isFinite(n)) throw new LoadSeriesConfigError(`${field} must be a number, received ${String(value)}`);
1601
+ return Math.round(n);
1602
+ }
1603
+ /**
1604
+ * Resolve the configuration, REFUSING anything out of range.
1605
+ *
1606
+ * An ABSENT field takes its default — that is not a refusal, it is a document
1607
+ * that has never been written. A PRESENT field outside its bounds throws.
1608
+ */
1609
+ function resolveLoadSeriesConfig(raw) {
1610
+ let cadenceSec = 10;
1611
+ if (raw.loadSeriesCadenceSec !== void 0 && raw.loadSeriesCadenceSec !== null) {
1612
+ cadenceSec = requireInteger(raw.loadSeriesCadenceSec, "loadSeriesCadenceSec");
1613
+ if (cadenceSec < 5 || cadenceSec > 60) throw new LoadSeriesConfigError(`load series cadence must be between 5 and 60 seconds — refused ${cadenceSec}`);
1614
+ }
1615
+ let retentionHours = 6;
1616
+ if (raw.loadSeriesRetentionHours !== void 0 && raw.loadSeriesRetentionHours !== null) {
1617
+ retentionHours = requireInteger(raw.loadSeriesRetentionHours, "loadSeriesRetentionHours");
1618
+ if (retentionHours < 1 || retentionHours > 72) throw new LoadSeriesConfigError(`load series retention must be between 1 and 72 hours — refused ${retentionHours}`);
1504
1619
  }
1505
- const nodeProcess = process;
1506
1620
  return {
1507
- openFds,
1508
- threadCount,
1509
- activeHandles: (nodeProcess._getActiveHandles?.() ?? []).length,
1510
- activeRequests: (nodeProcess._getActiveRequests?.() ?? []).length
1621
+ cadenceSec,
1622
+ retentionHours,
1623
+ maxRows: DEFAULT_MAX_ROWS
1511
1624
  };
1512
1625
  }
1513
- function getPressure(resource) {
1514
- if (!IS_LINUX) return null;
1515
- try {
1516
- const data = fs.readFileSync(`/proc/pressure/${resource}`, "utf-8");
1517
- const parseLine = (prefix) => {
1518
- const match = data.match(new RegExp(`${prefix} avg10=(\\d+\\.\\d+) avg60=(\\d+\\.\\d+) avg300=(\\d+\\.\\d+)`));
1519
- if (!match) return null;
1520
- return {
1521
- avg10: parseFloat(match[1]),
1522
- avg60: parseFloat(match[2]),
1523
- avg300: parseFloat(match[3])
1524
- };
1525
- };
1526
- const some = parseLine("some");
1527
- if (!some) return null;
1528
- return {
1529
- some,
1530
- full: parseLine("full")
1531
- };
1532
- } catch {
1533
- return null;
1534
- }
1626
+ /**
1627
+ * Project the cost of a configuration from the numbers the cluster is actually
1628
+ * reporting — nodes and processes OBSERVED, never a constant.
1629
+ *
1630
+ * This is what makes the knob honest: an operator raising retention to three
1631
+ * days sees the row count and the megabytes BEFORE applying it, rather than
1632
+ * discovering them afterwards. `MEASURED_BYTES_PER_ROW` is measured, not
1633
+ * guessed — see `load-series-store.ts`.
1634
+ */
1635
+ function projectLoadSeriesCost(input) {
1636
+ const samplesPerNodeWindow = input.config.retentionHours * 3600 / input.config.cadenceSec;
1637
+ const intendedRows = Math.round(samplesPerNodeWindow * Math.max(input.observedProcessRows, 0));
1638
+ const boundedRows = Math.min(intendedRows, input.config.maxRows);
1639
+ return {
1640
+ intendedRows,
1641
+ boundedRows,
1642
+ estimatedBytes: boundedRows * 87,
1643
+ capBites: intendedRows > input.config.maxRows
1644
+ };
1535
1645
  }
1536
- function execAsync(cmd, args, timeoutMs = 5e3) {
1537
- return new Promise((resolve, reject) => {
1538
- execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
1539
- if (err) return reject(err);
1540
- resolve(stdout);
1541
- });
1542
- });
1646
+ /** Human-readable one-liner for the settings form. */
1647
+ function describeLoadSeriesCost(projection) {
1648
+ const mib = (projection.estimatedBytes / 1048576).toFixed(1);
1649
+ const rows = projection.intendedRows.toLocaleString("en-US");
1650
+ const capped = projection.boundedRows.toLocaleString("en-US");
1651
+ if (!projection.capBites) return `This configuration retains ~${rows} rows ≈ ${mib} MiB on the hub database (NVMe cache), measured at 87 bytes per row.`;
1652
+ return `This configuration WANTS ~${rows} rows, above the ${projection.boundedRows.toLocaleString("en-US")}-row hard cap. The table will hold ~${capped} rows ≈ ${mib} MiB and the OLDEST samples will be evicted, so the effective window is shorter than the retention you asked for.`;
1543
1653
  }
1544
1654
  //#endregion
1545
- //#region src/builtins/native-metrics/load-series-store.ts
1546
- /**
1547
- * @durable class=audit owner=metrics-native
1548
- * write="one row per process per sample, appended in ONE transaction per
1549
- * sample (`settingsStore.insertMany`) by the HUB's `native-metrics`, off its
1550
- * subscription to `metrics.node-processes-snapshot`. Emission is
1551
- * fixed-cadence, so the write rate is deterministic: nodes x processes /
1552
- * cadence, 7.6 rows/s on the live fleet at the 10 s default. Only a sample
1553
- * the in-memory ring ACCEPTED is written, which is what makes the append
1554
- * idempotent without a unique key."
1555
- * retention="TWO bounds, swept together at most once a minute. By AGE, the
1556
- * operator's `loadSeriesRetentionHours` (default 6, the intention); by ROWS,
1557
- * DEFAULT_MAX_ROWS (the guarantee, which bites regardless of the age knob).
1558
- * Both prune oldest-first through a bounded page — never a full scan — and
1559
- * the row cap logs when it bites. Losing the table loses the cold window
1560
- * only: the ring keeps serving the last hour and the charts keep drawing."
1561
- */
1562
- var LOAD_SERIES_COLLECTION = "metrics:node-load-samples";
1655
+ //#region src/builtins/native-metrics/load-series-reader.ts
1563
1656
  /**
1564
- * Hard ceiling on the table, in ROWS.
1657
+ * The ONE reader over both retention tiers.
1565
1658
  *
1566
- * The guarantee behind the retention knob. Sized against the shipped default
1567
- * and against the worst configuration the knob can express:
1659
+ * The hot window is the in-memory ring; the cold one is
1660
+ * `metrics:node-load-samples`. A caller never learns which tier a point came
1661
+ * from, and that is a hard requirement rather than a nicety: a second reader
1662
+ * is how two charts start disagreeing about the same node, and it is how "the
1663
+ * ring says X, the table says Y" becomes a question nobody can answer.
1568
1664
  *
1569
- * | cadence | retention | rows (3 nodes, 76 processes) |
1570
- * | --- | --- | --- |
1571
- * | 10 s | 6 h (default) | 164 160 |
1572
- * | 5 s | 6 h | 328 320 |
1573
- * | 5 s | 72 h | 3 939 840 — capped here |
1665
+ * Three things happen here, in this order, and each one is load-bearing:
1574
1666
  *
1575
- * 500 000 rows x the measured 87 B = **41.5 MiB**, roughly 3x what the shipped
1576
- * default needs and far below the 2 M rows a three-day retention would reach.
1577
- * It is deliberately NOT a knob: the point of a guarantee is that it is not
1578
- * subject to the same hand that raised the intention. This is the discipline
1579
- * the in-memory ring already adopted (`DEFAULT_MAX_TOTAL_PROCESS_ROWS`) and
1580
- * the one the RTP pre-roll rings still lack — a maximum per instance and no
1581
- * budget at all for the whole, which reached 898 MB across 47 rings
1582
- * (`docs/architecture/load-ledger.md` #5).
1667
+ * 1. **Merge and dedupe on `atMs`.** The two tiers OVERLAP by construction —
1668
+ * the ring holds the last 180 snapshots and the table holds every snapshot
1669
+ * the ring accepted, including those same 180. A snapshot present in both
1670
+ * must contribute exactly once. Identity is `atMs`, because one node emits
1671
+ * one snapshot per tick and a timestamp already held IS that snapshot. The
1672
+ * HOT copy wins on a tie: it is the payload as it arrived, while the cold
1673
+ * one has been through the storage encode.
1674
+ * 2. **Fold per function.** The chart draws one series per addon, so the fold
1675
+ * happens here rather than in the browser.
1676
+ * 3. **Reduce, preserving the extremes.** See `load-series-fold.ts` for why
1677
+ * the representative of a bucket is its MAX and never its mean.
1678
+ *
1679
+ * A GAP stays a gap through all three. Emission is fixed-cadence, so a missing
1680
+ * interval means one thing — nobody reported — and nothing here fills it in.
1583
1681
  */
1584
- var DEFAULT_MAX_ROWS = 5e5;
1585
- var LOAD_SERIES_COLUMNS = [
1586
- (
1587
- /** `INTEGER PRIMARY KEY` = SQLite rowid alias: the key IS the row's address,
1588
- * so it costs no separate index and no stored string. */
1589
- {
1590
- name: "id",
1591
- type: "INTEGER",
1592
- primaryKey: true,
1593
- notNull: true
1594
- }),
1595
- {
1596
- name: "nodeId",
1597
- type: "TEXT",
1598
- notNull: true
1599
- },
1600
- (
1601
- /** The EMITTING node's timestamp for the sample this row belongs to. Every
1602
- * row of one sample shares it — that is what makes a sample reassemblable. */
1603
- {
1604
- name: "atMs",
1605
- type: "INTEGER",
1606
- notNull: true
1607
- }),
1608
- {
1609
- name: "pid",
1610
- type: "INTEGER",
1611
- notNull: true
1612
- },
1613
- (
1614
- /** `NULL` for a process no addon owns — `root` and `system` both. */
1615
- {
1616
- name: "addonId",
1617
- type: "TEXT"
1618
- }),
1619
- {
1620
- name: "classification",
1621
- type: "TEXT",
1622
- notNull: true
1623
- },
1624
- (
1625
- /** `ps pcpu` x 10. A LIFETIME average, not a rate — see `NodeProcess`. */
1626
- {
1627
- name: "cpuDeci",
1628
- type: "INTEGER",
1629
- notNull: true
1630
- }),
1631
- {
1632
- name: "rssMib",
1633
- type: "INTEGER",
1634
- notNull: true
1635
- },
1636
- (
1637
- /** Instantaneous main-thread CPU% x 10. `NULL` = UNKNOWN, never zero. */
1638
- {
1639
- name: "cpuMainDeci",
1640
- type: "INTEGER"
1641
- }),
1642
- (
1643
- /** Instantaneous V8-helper-pool CPU% x 10. `NULL` = UNKNOWN, never zero. */
1644
- {
1645
- name: "cpuGcDeci",
1646
- type: "INTEGER"
1647
- })
1648
- ];
1649
- var LOAD_SERIES_INDEXES = [(
1650
1682
  /**
1651
- * The ONE index, and it serves both jobs.
1683
+ * Merge the two tiers into one deduped, oldest-first list of samples.
1652
1684
  *
1653
- * Reads are always "this node, newer than T" — `nodeId` leads so the
1654
- * equality is a range scan and `atMs` supplies the order without a sort.
1655
- * Prunes are always "this node, oldest first" — the same index, walked from
1656
- * the other end. A second index on `atMs` alone was measured and rejected:
1657
- * it cost 23 B/row (196.0 → 172.8 with the two column drops) to serve a
1658
- * cross-node prune that a per-node loop over three nodes already serves.
1685
+ * Exported because the dedup is the property worth testing on its own: a
1686
+ * doubled point is invisible in a total and obvious only as a chart that reads
1687
+ * twice as loud as the machine.
1659
1688
  */
1660
- {
1661
- name: "idx_load_samples_node_at",
1662
- columns: ["nodeId", "atMs"]
1663
- })];
1664
- var BYTES_PER_MIB = 1048576;
1665
- /** Tenths → percent. `null` stays `null`: unknown is not zero. */
1666
- function fromDeci(value) {
1667
- return value === null ? null : Math.round(value) / 10;
1689
+ function mergeSamples(cold, hot) {
1690
+ const byAt = /* @__PURE__ */ new Map();
1691
+ for (const sample of cold) byAt.set(sample.atMs, sample);
1692
+ for (const sample of hot) byAt.set(sample.atMs, sample);
1693
+ return [...byAt.values()].toSorted((a, b) => a.atMs - b.atMs);
1668
1694
  }
1669
- /** Percent → tenths, lossless because both producers already round to tenths. */
1670
- function toDeci(value) {
1671
- return value === null ? null : Math.round(value * 10);
1695
+ /** `RetainedProcess` is already the fold's row shape — named, not re-derived. */
1696
+ function toFoldRows(processes) {
1697
+ return processes;
1672
1698
  }
1673
- /** Storage row → the shape a reader gets. The ONE decode. */
1674
- function rowToProcess(row) {
1675
- return {
1676
- pid: row.pid,
1677
- addonId: row.addonId,
1678
- classification: row.classification,
1679
- cpuPercent: fromDeci(row.cpuDeci) ?? 0,
1680
- memoryRssBytes: row.rssMib * BYTES_PER_MIB,
1681
- cpuMainPercent: fromDeci(row.cpuMainDeci),
1682
- cpuGcPercent: fromDeci(row.cpuGcDeci)
1699
+ /**
1700
+ * Merge, fold and reduce. The whole read path in one pure function, so every
1701
+ * property that matters — dedup, peak survival, an empty bucket staying empty
1702
+ * — is testable without a database or a ring.
1703
+ */
1704
+ function mergeLoadSeries(input) {
1705
+ const samples = mergeSamples(input.cold, input.hot);
1706
+ if (samples.length === 0) return {
1707
+ series: [],
1708
+ bucketMs: input.cadenceMs,
1709
+ retainedSamples: 0,
1710
+ oldestAtMs: null
1711
+ };
1712
+ const byKey = /* @__PURE__ */ new Map();
1713
+ for (const sample of samples) for (const bucket of foldSnapshotByFunction(toFoldRows(sample.processes), sample.atMs)) {
1714
+ const held = byKey.get(bucket.key);
1715
+ if (held === void 0) byKey.set(bucket.key, {
1716
+ kind: bucket.kind,
1717
+ points: [bucket.point]
1718
+ });
1719
+ else held.points.push(bucket.point);
1720
+ }
1721
+ const oldestAtMs = samples[0]?.atMs ?? null;
1722
+ const newestAtMs = samples.at(-1)?.atMs ?? oldestAtMs;
1723
+ const spanMs = oldestAtMs === null || newestAtMs === null ? 0 : newestAtMs - oldestAtMs + input.cadenceMs;
1724
+ const bucketMs = input.maxPoints === void 0 ? input.cadenceMs : resolveBucketMs(spanMs, input.cadenceMs, input.maxPoints);
1725
+ const origin = oldestAtMs ?? 0;
1726
+ const series = [...byKey.entries()].map(([key, held]) => ({
1727
+ key,
1728
+ kind: held.kind,
1729
+ points: bucketMs > input.cadenceMs ? reducePoints(held.points, bucketMs, origin) : held.points
1730
+ }));
1731
+ const weight = (s) => {
1732
+ const last = s.points.at(-1);
1733
+ if (last === void 0) return -1;
1734
+ if (last.cpuMainPercent !== null && last.cpuGcPercent !== null) return last.cpuMainPercent + last.cpuGcPercent;
1735
+ return last.cpuLifetimePercent;
1683
1736
  };
1684
- }
1685
- /** The shape a reader gets → the storage row. The ONE encode. */
1686
- function processToRow(nodeId, atMs, process) {
1687
1737
  return {
1688
- nodeId,
1689
- atMs,
1690
- pid: process.pid,
1691
- addonId: process.addonId,
1692
- classification: process.classification,
1693
- cpuDeci: toDeci(process.cpuPercent) ?? 0,
1694
- rssMib: Math.round(process.memoryRssBytes / BYTES_PER_MIB),
1695
- cpuMainDeci: toDeci(process.cpuMainPercent),
1696
- cpuGcDeci: toDeci(process.cpuGcPercent)
1738
+ series: series.toSorted((a, b) => {
1739
+ const d = weight(b) - weight(a);
1740
+ return d !== 0 ? d : a.key.localeCompare(b.key);
1741
+ }),
1742
+ bucketMs,
1743
+ retainedSamples: samples.length,
1744
+ oldestAtMs
1697
1745
  };
1698
1746
  }
1699
- /** Group flat rows into samples, oldest-first. A sample is one `atMs`. */
1700
- function rowsToSamples(rows) {
1701
- const byAt = /* @__PURE__ */ new Map();
1702
- for (const row of rows) {
1703
- const held = byAt.get(row.atMs);
1704
- if (held === void 0) byAt.set(row.atMs, [rowToProcess(row)]);
1705
- else held.push(rowToProcess(row));
1747
+ //#endregion
1748
+ //#region src/builtins/native-metrics/gpu-probe.ts
1749
+ /**
1750
+ * The Linux GPU probe, forking only when there is something to fork.
1751
+ *
1752
+ * `nvidia-smi` was spawned on every sample — twelve times a minute from
1753
+ * hub-main — on a host with an Intel i915 and no NVIDIA driver. Each spawn
1754
+ * was a synchronous `fork()` of the largest process on the host (D441: p50
1755
+ * 91 ms at 5.8 GB RSS), and each one failed at `exec` and yielded `null`.
1756
+ * Twelve forks a minute bought nothing (D452).
1757
+ *
1758
+ * Whether the binary exists is a question `access(2)` answers on the libuv
1759
+ * pool without a fork. It is asked on EVERY call rather than latched: a handful
1760
+ * of `stat`s every five seconds costs nothing, and a latch is a second fact
1761
+ * about the host that can go stale — the same shape as the caches D224
1762
+ * forbids.
1763
+ */
1764
+ var nodeAccessCheck = (path) => access(path, constants.X_OK);
1765
+ /**
1766
+ * The first `PATH` entry under which `binary` is executable, or `null`.
1767
+ * An empty or missing `PATH` asks nothing.
1768
+ */
1769
+ async function findOnPath(binary, pathEnv, check = nodeAccessCheck) {
1770
+ const dirs = (pathEnv ?? "").split(":").filter((dir) => dir.length > 0);
1771
+ for (const dir of dirs) {
1772
+ const candidate = `${dir}/${binary}`;
1773
+ try {
1774
+ await check(candidate);
1775
+ return candidate;
1776
+ } catch {}
1706
1777
  }
1707
- return [...byAt.entries()].toSorted((a, b) => a[0] - b[0]).map(([atMs, processes]) => ({
1708
- atMs,
1709
- processes
1710
- }));
1778
+ return null;
1711
1779
  }
1780
+ var NVIDIA_SMI_ARGS = ["--query-gpu=utilization.gpu,name,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits"];
1712
1781
  /**
1713
- * The cold tier. Append-only, bounded, and never the authority on anything the
1714
- * hot ring can answer.
1715
- *
1716
- * Every method is best-effort and says so in the log rather than throwing at
1717
- * the emitter: a storage hiccup must cost a gap in a chart, never a dropped
1718
- * snapshot on the bus.
1782
+ * `nvidia-smi`'s one-line CSV as a `MetricsGpuInfo`, or `null` when the
1783
+ * binary is absent, fails, or prints something else. Never throws.
1719
1784
  */
1720
- var LoadSeriesStore = class {
1721
- declared = false;
1722
- lastPruneAtMs = 0;
1723
- store;
1724
- logger;
1725
- nowFn;
1726
- prunePageRows;
1727
- pruneIntervalMs;
1728
- constructor(deps) {
1729
- this.store = deps.store;
1730
- this.logger = deps.logger;
1731
- this.nowFn = deps.now ?? (() => Date.now());
1732
- this.prunePageRows = deps.prunePageRows ?? 2e4;
1733
- this.pruneIntervalMs = deps.pruneIntervalMs ?? 6e4;
1785
+ async function readNvidiaGpu(deps) {
1786
+ const binary = await deps.findOnPath("nvidia-smi");
1787
+ if (binary === null) return null;
1788
+ let stdout;
1789
+ try {
1790
+ stdout = await deps.exec(binary, NVIDIA_SMI_ARGS);
1791
+ } catch {
1792
+ return null;
1793
+ }
1794
+ const parts = stdout.trim().split(",").map((s) => s.trim());
1795
+ if (parts.length < 5) return null;
1796
+ return {
1797
+ utilization: parseInt(parts[0], 10) || 0,
1798
+ model: parts[1] ?? "Unknown GPU",
1799
+ memoryUsedBytes: (parseInt(parts[2], 10) || 0) * 1024 * 1024,
1800
+ memoryTotalBytes: (parseInt(parts[3], 10) || 0) * 1024 * 1024,
1801
+ temperature: parseInt(parts[4], 10) || null
1802
+ };
1803
+ }
1804
+ //#endregion
1805
+ //#region src/builtins/native-metrics/native-metrics-provider.ts
1806
+ var IS_DARWIN = os.platform() === "darwin";
1807
+ var IS_LINUX = os.platform() === "linux";
1808
+ var NativeMetricsProvider = class {
1809
+ id = "native";
1810
+ cachedSnapshot = null;
1811
+ samplingTimer = null;
1812
+ macMemTimer = null;
1813
+ prevCpu;
1814
+ diagnostics = null;
1815
+ consecutiveSampleErrors = 0;
1816
+ constructor() {
1817
+ this.prevCpu = aggregateCpuTimes();
1818
+ }
1819
+ /**
1820
+ * Optional diagnostics sink for sampling errors. The first failure and
1821
+ * every Nth failure thereafter are reported so a permanently-broken
1822
+ * collector doesn't spam logs but is still observable.
1823
+ */
1824
+ setDiagnostics(diag) {
1825
+ this.diagnostics = diag;
1826
+ }
1827
+ startSampling(intervalMs) {
1828
+ if (IS_DARWIN) {
1829
+ sampleMacMemory(this);
1830
+ this.macMemTimer = setInterval(() => sampleMacMemory(this), 5e3);
1831
+ if (this.macMemTimer.unref) this.macMemTimer.unref();
1832
+ }
1833
+ this.collectSnapshot().then((s) => {
1834
+ this.cachedSnapshot = s;
1835
+ this.consecutiveSampleErrors = 0;
1836
+ }).catch((err) => this.reportSampleError(err));
1837
+ this.samplingTimer = setInterval(() => {
1838
+ this.collectSnapshot().then((s) => {
1839
+ this.cachedSnapshot = s;
1840
+ this.consecutiveSampleErrors = 0;
1841
+ }).catch((err) => this.reportSampleError(err));
1842
+ }, intervalMs);
1843
+ if (this.samplingTimer.unref) this.samplingTimer.unref();
1844
+ }
1845
+ reportSampleError(err) {
1846
+ this.consecutiveSampleErrors++;
1847
+ if (!this.diagnostics) return;
1848
+ if (!(this.consecutiveSampleErrors === 1 || this.consecutiveSampleErrors % 60 === 0)) return;
1849
+ this.diagnostics.warn("metrics-provider sample failed", {
1850
+ consecutiveErrors: this.consecutiveSampleErrors,
1851
+ error: err instanceof Error ? err.message : String(err)
1852
+ });
1853
+ }
1854
+ stopSampling() {
1855
+ if (this.samplingTimer) {
1856
+ clearInterval(this.samplingTimer);
1857
+ this.samplingTimer = null;
1858
+ }
1859
+ if (this.macMemTimer) {
1860
+ clearInterval(this.macMemTimer);
1861
+ this.macMemTimer = null;
1862
+ }
1863
+ }
1864
+ async collectSnapshot() {
1865
+ const cpu = this.sampleCpu();
1866
+ const memory = this.getMemoryInfo();
1867
+ const processInfo = getProcessResources();
1868
+ const [gpu, network, disk, cpuTemp] = await Promise.all([
1869
+ getGpuInfoInternal().catch(() => null),
1870
+ takeNetworkIoSnapshot(),
1871
+ takeDiskIoSnapshot(),
1872
+ getCpuTemperatureInternal().catch(() => null)
1873
+ ]);
1874
+ const snapshot = {
1875
+ cpu,
1876
+ memory,
1877
+ gpu,
1878
+ network,
1879
+ disk,
1880
+ pressure: {
1881
+ cpu: getPressure("cpu"),
1882
+ memory: getPressure("memory"),
1883
+ io: getPressure("io")
1884
+ },
1885
+ process: processInfo,
1886
+ cpuTemperature: cpuTemp,
1887
+ timestampMs: Date.now()
1888
+ };
1889
+ this.cachedSnapshot = snapshot;
1890
+ return snapshot;
1891
+ }
1892
+ async getCached() {
1893
+ return this.cachedSnapshot;
1894
+ }
1895
+ async getCurrent() {
1896
+ const s = this.cachedSnapshot ?? await this.collectSnapshot();
1897
+ const mb = (bytes) => Math.round(bytes / 1024 / 1024);
1898
+ return {
1899
+ cpuPercent: s.cpu.total,
1900
+ memoryPercent: s.memory.percent,
1901
+ memoryUsedMB: mb(s.memory.usedBytes),
1902
+ memoryTotalMB: mb(s.memory.totalBytes),
1903
+ temperature: s.cpuTemperature ?? void 0,
1904
+ gpuPercent: s.gpu?.utilization,
1905
+ gpuMemoryPercent: s.gpu && s.gpu.memoryTotalBytes > 0 ? Math.round(s.gpu.memoryUsedBytes / s.gpu.memoryTotalBytes * 1e3) / 10 : void 0
1906
+ };
1907
+ }
1908
+ async getDiskSpace(input) {
1909
+ return getDiskSpaceInternal(input.dirPath);
1910
+ }
1911
+ async getGpuInfo() {
1912
+ return getGpuInfoInternal();
1913
+ }
1914
+ async getCpuTemperature() {
1915
+ return getCpuTemperatureInternal();
1916
+ }
1917
+ async getProcessStats(input) {
1918
+ const { getPidStats } = await import("../../resource-monitor-Vp0bPV3U.mjs").then((n) => n.r);
1919
+ const raw = await getPidStats(input.pids);
1920
+ const result = [];
1921
+ for (const [pid, s] of raw) result.push({
1922
+ pid,
1923
+ cpu: s.cpu,
1924
+ memory: s.memory,
1925
+ privateBytes: s.privateBytes,
1926
+ sharedBytes: s.sharedBytes
1927
+ });
1928
+ return result;
1929
+ }
1930
+ sampleCpu() {
1931
+ const curr = aggregateCpuTimes();
1932
+ const prev = this.prevCpu;
1933
+ this.prevCpu = curr;
1934
+ const dUser = curr.user - prev.user;
1935
+ const dNice = curr.nice - prev.nice;
1936
+ const dSys = curr.sys - prev.sys;
1937
+ const dIrq = curr.irq - prev.irq;
1938
+ const dIdle = curr.idle - prev.idle;
1939
+ const dTotal = dUser + dNice + dSys + dIrq + dIdle;
1940
+ const pct = (v) => dTotal === 0 ? 0 : Math.round(v / dTotal * 1e3) / 10;
1941
+ return {
1942
+ total: pct(dUser + dNice + dSys + dIrq),
1943
+ user: pct(dUser),
1944
+ system: pct(dSys),
1945
+ irq: pct(dIrq),
1946
+ nice: pct(dNice),
1947
+ loadAvg: os.loadavg(),
1948
+ cores: os.cpus().length
1949
+ };
1950
+ }
1951
+ /** Cached macOS memory (set by sampleMacMemory timer). */
1952
+ _cachedMacMem = null;
1953
+ getMemoryInfo() {
1954
+ const total = os.totalmem();
1955
+ if (total === 0) return {
1956
+ percent: 0,
1957
+ totalBytes: 0,
1958
+ usedBytes: 0,
1959
+ availableBytes: 0,
1960
+ swapUsedBytes: 0,
1961
+ swapTotalBytes: 0
1962
+ };
1963
+ if (IS_DARWIN && this._cachedMacMem !== null) return this._cachedMacMem;
1964
+ const free = os.freemem();
1965
+ const used = total - free;
1966
+ let swapUsedBytes = 0;
1967
+ let swapTotalBytes = 0;
1968
+ if (IS_LINUX) try {
1969
+ const meminfo = fs.readFileSync("/proc/meminfo", "utf-8");
1970
+ const swapTotal = parseInt(meminfo.match(/SwapTotal:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
1971
+ const swapFree = parseInt(meminfo.match(/SwapFree:\s+(\d+)/)?.[1] ?? "0", 10) * 1024;
1972
+ swapTotalBytes = swapTotal;
1973
+ swapUsedBytes = swapTotal - swapFree;
1974
+ } catch {}
1975
+ return {
1976
+ percent: Math.round((total - free) / total * 1e3) / 10,
1977
+ totalBytes: total,
1978
+ usedBytes: used,
1979
+ availableBytes: free,
1980
+ swapUsedBytes,
1981
+ swapTotalBytes
1982
+ };
1734
1983
  }
1735
- /** Idempotently declare the collection. `false` when the store refused. */
1736
- async declare() {
1737
- if (this.declared) return true;
1738
- try {
1739
- await this.store.declareCollection.mutate({
1740
- collection: LOAD_SERIES_COLLECTION,
1741
- columns: [...LOAD_SERIES_COLUMNS],
1742
- indexes: LOAD_SERIES_INDEXES.map((i) => ({
1743
- name: i.name,
1744
- columns: [...i.columns]
1745
- }))
1746
- });
1747
- this.declared = true;
1748
- return true;
1749
- } catch (err) {
1750
- this.logger.warn("load series declareCollection failed — nothing will be retained on disk", { meta: {
1751
- collection: LOAD_SERIES_COLLECTION,
1752
- error: errMsg(err)
1753
- } });
1754
- return false;
1755
- }
1984
+ };
1985
+ function aggregateCpuTimes() {
1986
+ const cpus = os.cpus();
1987
+ let user = 0, nice = 0, sys = 0, irq = 0, idle = 0;
1988
+ for (const cpu of cpus) {
1989
+ user += cpu.times.user;
1990
+ nice += cpu.times.nice;
1991
+ sys += cpu.times.sys;
1992
+ irq += cpu.times.irq;
1993
+ idle += cpu.times.idle;
1756
1994
  }
1757
- /**
1758
- * Append ONE sample — every process row of it — in ONE transaction.
1759
- *
1760
- * Never a write per row. `insertMany` exists for exactly this: at the 10 s
1761
- * default the fleet produces 7.6 rows/s, and 7.6 separate commits per second
1762
- * on the connection that also serves every cluster-wide configuration read
1763
- * is a constant load nobody asked for.
1764
- */
1765
- async append(nodeId, atMs, processes) {
1766
- if (processes.length === 0) return 0;
1767
- if (!await this.declare()) return 0;
1768
- const records = processes.map((p) => ({ data: { ...processToRow(nodeId, atMs, p) } }));
1995
+ return {
1996
+ user,
1997
+ nice,
1998
+ sys,
1999
+ irq,
2000
+ idle
2001
+ };
2002
+ }
2003
+ function sampleMacMemory(provider) {
2004
+ try {
2005
+ const output = execFileSync("vm_stat", {
2006
+ timeout: 2e3,
2007
+ encoding: "utf-8"
2008
+ });
2009
+ const pageSize = 16384;
2010
+ const parse = (label) => {
2011
+ const match = output.match(new RegExp(`${label}:\\s+(\\d+)`));
2012
+ return match ? parseInt(match[1], 10) * pageSize : 0;
2013
+ };
2014
+ const free = parse("Pages free");
2015
+ const inactive = parse("Pages inactive");
2016
+ const purgeable = parse("Pages purgeable");
2017
+ const speculative = parse("Pages speculative");
2018
+ const available = free + inactive + purgeable + speculative;
2019
+ const total = os.totalmem();
2020
+ const used = total - available;
2021
+ let swapUsedBytes = 0;
2022
+ let swapTotalBytes = 0;
1769
2023
  try {
1770
- const { inserted } = await this.store.insertMany.mutate({
1771
- collection: LOAD_SERIES_COLLECTION,
1772
- records
2024
+ const swapOut = execFileSync("sysctl", ["-n", "vm.swapusage"], {
2025
+ timeout: 2e3,
2026
+ encoding: "utf-8"
1773
2027
  });
1774
- return inserted;
1775
- } catch (err) {
1776
- this.logger.warn("load series sample not retained — this interval will be missing", { meta: {
1777
- nodeId,
1778
- atMs,
1779
- rows: processes.length,
1780
- error: errMsg(err)
1781
- } });
1782
- return 0;
1783
- }
2028
+ const totalMatch = swapOut.match(/total\s*=\s*([\d.]+)M/);
2029
+ const usedMatch = swapOut.match(/used\s*=\s*([\d.]+)M/);
2030
+ if (totalMatch) swapTotalBytes = parseFloat(totalMatch[1]) * 1024 * 1024;
2031
+ if (usedMatch) swapUsedBytes = parseFloat(usedMatch[1]) * 1024 * 1024;
2032
+ } catch {}
2033
+ provider._cachedMacMem = {
2034
+ percent: Math.round(used / total * 1e3) / 10,
2035
+ totalBytes: total,
2036
+ usedBytes: used,
2037
+ availableBytes: available,
2038
+ swapUsedBytes,
2039
+ swapTotalBytes
2040
+ };
2041
+ } catch {
2042
+ provider._cachedMacMem = null;
1784
2043
  }
1785
- /**
1786
- * Read one node's cold samples, oldest-first.
1787
- *
1788
- * `sinceMs` is EXCLUSIVE, matching the ring, so a caller passing the newest
1789
- * timestamp it holds gets only what it is missing. `limitRows` bounds the
1790
- * read in ROWS (not samples) because rows are what the query costs.
1791
- */
1792
- async read(nodeId, sinceMs, limitRows) {
1793
- if (!await this.declare()) return [];
1794
- try {
1795
- const records = await this.store.query.query({
1796
- collection: LOAD_SERIES_COLLECTION,
1797
- filter: {
1798
- where: { nodeId },
1799
- whereBetween: { atMs: [sinceMs + 1, Number.MAX_SAFE_INTEGER] },
1800
- orderBy: {
1801
- field: "atMs",
1802
- direction: "asc"
1803
- },
1804
- limit: limitRows
1805
- }
2044
+ }
2045
+ function getDiskSpaceInternal(dirPath) {
2046
+ return new Promise((resolve, reject) => {
2047
+ fs.statfs(dirPath, (err, stats) => {
2048
+ if (err) return reject(err);
2049
+ const totalBytes = stats.blocks * stats.bsize;
2050
+ const availableBytes = stats.bavail * stats.bsize;
2051
+ const usedBytes = totalBytes - stats.bfree * stats.bsize;
2052
+ resolve({
2053
+ path: dirPath,
2054
+ totalBytes,
2055
+ usedBytes,
2056
+ availableBytes,
2057
+ percent: totalBytes > 0 ? Math.round(usedBytes / totalBytes * 1e3) / 10 : 0
1806
2058
  });
1807
- const rows = [];
1808
- for (const record of records) {
1809
- const row = recordToRow(record.data);
1810
- if (row !== null) rows.push(row);
1811
- }
1812
- return rowsToSamples(rows);
1813
- } catch (err) {
1814
- this.logger.warn("load series cold read failed — answering from the hot window only", { meta: {
1815
- nodeId,
1816
- sinceMs,
1817
- error: errMsg(err)
1818
- } });
1819
- return [];
2059
+ });
2060
+ });
2061
+ }
2062
+ async function takeDiskIoSnapshot() {
2063
+ const now = Date.now();
2064
+ if (IS_LINUX) try {
2065
+ const data = fs.readFileSync("/proc/diskstats", "utf-8");
2066
+ let readOps = 0, readBytes = 0, writeOps = 0, writeBytes = 0;
2067
+ for (const line of data.split("\n")) {
2068
+ const parts = line.trim().split(/\s+/);
2069
+ if (parts.length < 14) continue;
2070
+ const devName = parts[2];
2071
+ if (/\d+$/.test(devName) && !/^nvme\d+n\d+$/.test(devName)) continue;
2072
+ readOps += parseInt(parts[3], 10) || 0;
2073
+ readBytes += (parseInt(parts[5], 10) || 0) * 512;
2074
+ writeOps += parseInt(parts[7], 10) || 0;
2075
+ writeBytes += (parseInt(parts[9], 10) || 0) * 512;
1820
2076
  }
1821
- }
1822
- /**
1823
- * Enforce BOTH bounds, oldest-first, through a bounded page each.
1824
- *
1825
- * Rate-limited to {@link pruneIntervalMs}: a bound is not a deadline, and the
1826
- * append path must not pay a sweep on every sample.
1827
- */
1828
- async prune(nodeIds, retention, force = false) {
1829
- const now = this.nowFn();
1830
- if (!force && now - this.lastPruneAtMs < this.pruneIntervalMs) return null;
1831
- this.lastPruneAtMs = now;
1832
- if (!await this.declare()) return null;
1833
- let deletedByAge = 0;
1834
- let deletedByCap = 0;
1835
- let rowsExamined = 0;
1836
- const ageCutoff = now - retention.retentionHours * 36e5;
1837
- for (const nodeId of nodeIds) {
1838
- const outcome = await this.pruneNodeToCutoff(nodeId, ageCutoff);
1839
- deletedByAge += outcome.deleted;
1840
- rowsExamined += outcome.examined;
2077
+ return {
2078
+ readBytes,
2079
+ writeBytes,
2080
+ readOps,
2081
+ writeOps,
2082
+ timestampMs: now
2083
+ };
2084
+ } catch {}
2085
+ return {
2086
+ readBytes: 0,
2087
+ writeBytes: 0,
2088
+ readOps: 0,
2089
+ writeOps: 0,
2090
+ timestampMs: now
2091
+ };
2092
+ }
2093
+ async function takeNetworkIoSnapshot() {
2094
+ const now = Date.now();
2095
+ if (IS_LINUX) try {
2096
+ const data = fs.readFileSync("/proc/net/dev", "utf-8");
2097
+ let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
2098
+ for (const line of data.split("\n")) {
2099
+ const match = line.match(/^\s*(\w+):\s+(.+)$/);
2100
+ if (!match) continue;
2101
+ if (match[1] === "lo") continue;
2102
+ const cols = match[2].trim().split(/\s+/);
2103
+ rxBytes += parseInt(cols[0], 10) || 0;
2104
+ rxPackets += parseInt(cols[1], 10) || 0;
2105
+ rxErrors += parseInt(cols[2], 10) || 0;
2106
+ txBytes += parseInt(cols[8], 10) || 0;
2107
+ txPackets += parseInt(cols[9], 10) || 0;
2108
+ txErrors += parseInt(cols[10], 10) || 0;
1841
2109
  }
1842
- const total = await this.count();
1843
- const excess = total === null ? 0 : total - retention.maxRows;
1844
- if (excess > 0) {
1845
- const outcome = await this.pruneOldestRows(nodeIds, excess);
1846
- deletedByCap = outcome.deleted;
1847
- rowsExamined += outcome.examined;
1848
- this.logger.warn("load series ROW CAP bit — evicting the oldest samples", { meta: {
1849
- collection: LOAD_SERIES_COLLECTION,
1850
- rows: total,
1851
- cap: retention.maxRows,
1852
- over: excess,
1853
- deleted: outcome.deleted,
1854
- retentionHours: retention.retentionHours,
1855
- hint: "lower the retention or the sampling cadence — the cap is the guarantee, not the intention"
1856
- } });
2110
+ return {
2111
+ rxBytes,
2112
+ txBytes,
2113
+ rxPackets,
2114
+ txPackets,
2115
+ rxErrors,
2116
+ txErrors,
2117
+ timestampMs: now
2118
+ };
2119
+ } catch {}
2120
+ if (IS_DARWIN) try {
2121
+ const stdout = await execAsync("netstat", ["-ib"]);
2122
+ let rxBytes = 0, txBytes = 0, rxPackets = 0, txPackets = 0, rxErrors = 0, txErrors = 0;
2123
+ for (const line of stdout.split("\n")) {
2124
+ const parts = line.trim().split(/\s+/);
2125
+ if (parts.length < 11) continue;
2126
+ if (parts[0] === "lo0" || parts[0] === "Name") continue;
2127
+ if (!parts[2]?.startsWith("Link#")) continue;
2128
+ rxPackets += parseInt(parts[4], 10) || 0;
2129
+ rxErrors += parseInt(parts[5], 10) || 0;
2130
+ rxBytes += parseInt(parts[6], 10) || 0;
2131
+ txPackets += parseInt(parts[7], 10) || 0;
2132
+ txErrors += parseInt(parts[8], 10) || 0;
2133
+ txBytes += parseInt(parts[9], 10) || 0;
1857
2134
  }
1858
2135
  return {
1859
- deletedByAge,
1860
- deletedByCap,
1861
- rowsExamined,
1862
- capBit: excess > 0
2136
+ rxBytes,
2137
+ txBytes,
2138
+ rxPackets,
2139
+ txPackets,
2140
+ rxErrors,
2141
+ txErrors,
2142
+ timestampMs: now
1863
2143
  };
2144
+ } catch {}
2145
+ return {
2146
+ rxBytes: 0,
2147
+ txBytes: 0,
2148
+ rxPackets: 0,
2149
+ txPackets: 0,
2150
+ rxErrors: 0,
2151
+ txErrors: 0,
2152
+ timestampMs: now
2153
+ };
2154
+ }
2155
+ async function getCpuTemperatureInternal() {
2156
+ if (IS_LINUX) for (const p of ["/sys/class/thermal/thermal_zone0/temp", "/sys/class/hwmon/hwmon0/temp1_input"]) try {
2157
+ const raw = fs.readFileSync(p, "utf-8").trim();
2158
+ const millideg = parseInt(raw, 10);
2159
+ if (!isNaN(millideg)) return Math.round(millideg / 100) / 10;
2160
+ } catch {
2161
+ continue;
1864
2162
  }
1865
- /** Total rows, or `null` when the store could not answer. */
1866
- async count() {
1867
- if (!await this.declare()) return null;
2163
+ if (IS_DARWIN) try {
2164
+ const match = (await execAsync("sudo", [
2165
+ "-n",
2166
+ "powermetrics",
2167
+ "--samplers",
2168
+ "smc",
2169
+ "-i",
2170
+ "1",
2171
+ "-n",
2172
+ "1"
2173
+ ], 3e3)).match(/CPU die temperature:\s+([\d.]+)\s*C/);
2174
+ if (match) return Math.round(parseFloat(match[1]) * 10) / 10;
2175
+ } catch {}
2176
+ return null;
2177
+ }
2178
+ async function getGpuInfoInternal() {
2179
+ if (IS_DARWIN) try {
2180
+ const utilMatch = (await execAsync("ioreg", [
2181
+ "-r",
2182
+ "-d",
2183
+ "1",
2184
+ "-c",
2185
+ "IOAccelerator"
2186
+ ])).match(/"GPU Core Utilization"\s*=\s*(\d+)/);
2187
+ const util = utilMatch ? parseInt(utilMatch[1], 10) : 0;
2188
+ const utilPct = util > 1e3 ? Math.round(util / 1e7) : util;
2189
+ return {
2190
+ utilization: Math.min(100, utilPct),
2191
+ model: "Apple Silicon GPU",
2192
+ memoryUsedBytes: 0,
2193
+ memoryTotalBytes: 0,
2194
+ temperature: null
2195
+ };
2196
+ } catch {}
2197
+ if (IS_LINUX) return readNvidiaGpu({
2198
+ findOnPath: (binary) => findOnPath(binary, process.env["PATH"]),
2199
+ exec: (command, args) => execAsync(command, [...args])
2200
+ });
2201
+ return null;
2202
+ }
2203
+ function getProcessResources() {
2204
+ let openFds = 0;
2205
+ let threadCount = 0;
2206
+ if (IS_LINUX) {
1868
2207
  try {
1869
- return await this.store.count.query({ collection: LOAD_SERIES_COLLECTION });
1870
- } catch (err) {
1871
- this.logger.warn("load series count failed — the row cap is not enforced this pass", { meta: { error: errMsg(err) } });
1872
- return null;
1873
- }
1874
- }
1875
- /**
1876
- * Delete this node's rows older than `cutoff`, at most one page's worth.
1877
- *
1878
- * The page is the whole point. `deleteWhere({ atMs: [0, cutoff] })` on its
1879
- * own is one statement but an UNBOUNDED one — a first pass after a retention
1880
- * change would delete millions of rows inside a single stalling transaction.
1881
- * So the page is read first (keys only, ordered by the index), its last
1882
- * `atMs` becomes the EFFECTIVE cutoff, and the delete is bounded by it.
1883
- */
1884
- async pruneNodeToCutoff(nodeId, cutoff) {
2208
+ openFds = fs.readdirSync(`/proc/${process.pid}/fd`).length;
2209
+ } catch {}
1885
2210
  try {
1886
- const page = await this.store.query.query({
1887
- collection: LOAD_SERIES_COLLECTION,
1888
- filter: {
1889
- where: { nodeId },
1890
- whereBetween: { atMs: [0, cutoff] },
1891
- orderBy: {
1892
- field: "atMs",
1893
- direction: "asc"
1894
- },
1895
- limit: this.prunePageRows
1896
- },
1897
- columns: ["atMs"]
1898
- });
1899
- if (page.length === 0) return {
1900
- deleted: 0,
1901
- examined: 0
1902
- };
1903
- const effectiveCutoff = Number(page.at(-1)?.data["atMs"]);
1904
- if (!Number.isFinite(effectiveCutoff)) return {
1905
- deleted: 0,
1906
- examined: page.length
1907
- };
1908
- const { deleted } = await this.store.deleteWhere.mutate({
1909
- collection: LOAD_SERIES_COLLECTION,
1910
- filter: {
1911
- where: { nodeId },
1912
- whereBetween: { atMs: [0, effectiveCutoff] }
1913
- }
1914
- });
1915
- return {
1916
- deleted,
1917
- examined: page.length
1918
- };
1919
- } catch (err) {
1920
- this.logger.warn("load series age prune failed — the table keeps growing this pass", { meta: {
1921
- nodeId,
1922
- cutoff,
1923
- error: errMsg(err)
1924
- } });
2211
+ const match = fs.readFileSync(`/proc/${process.pid}/status`, "utf-8").match(/Threads:\s+(\d+)/);
2212
+ if (match) threadCount = parseInt(match[1], 10);
2213
+ } catch {}
2214
+ }
2215
+ const nodeProcess = process;
2216
+ return {
2217
+ openFds,
2218
+ threadCount,
2219
+ activeHandles: (nodeProcess._getActiveHandles?.() ?? []).length,
2220
+ activeRequests: (nodeProcess._getActiveRequests?.() ?? []).length
2221
+ };
2222
+ }
2223
+ function getPressure(resource) {
2224
+ if (!IS_LINUX) return null;
2225
+ try {
2226
+ const data = fs.readFileSync(`/proc/pressure/${resource}`, "utf-8");
2227
+ const parseLine = (prefix) => {
2228
+ const match = data.match(new RegExp(`${prefix} avg10=(\\d+\\.\\d+) avg60=(\\d+\\.\\d+) avg300=(\\d+\\.\\d+)`));
2229
+ if (!match) return null;
1925
2230
  return {
1926
- deleted: 0,
1927
- examined: 0
2231
+ avg10: parseFloat(match[1]),
2232
+ avg60: parseFloat(match[2]),
2233
+ avg300: parseFloat(match[3])
1928
2234
  };
1929
- }
1930
- }
1931
- /**
1932
- * Drop the oldest rows across the known nodes until `excess` is covered.
1933
- *
1934
- * Same bounded-page technique, walked per node so the one index serves it.
1935
- * The node holding the oldest rows pays first, which is also the node
1936
- * producing the pressure when a runaway process count is the cause.
1937
- */
1938
- async pruneOldestRows(nodeIds, excess) {
1939
- let remaining = Math.min(excess, this.prunePageRows);
1940
- let deleted = 0;
1941
- let examined = 0;
1942
- for (const nodeId of nodeIds) {
1943
- if (remaining <= 0) break;
1944
- try {
1945
- const page = await this.store.query.query({
1946
- collection: LOAD_SERIES_COLLECTION,
1947
- filter: {
1948
- where: { nodeId },
1949
- orderBy: {
1950
- field: "atMs",
1951
- direction: "asc"
1952
- },
1953
- limit: remaining
1954
- },
1955
- columns: ["atMs"]
1956
- });
1957
- examined += page.length;
1958
- if (page.length === 0) continue;
1959
- const cutoff = Number(page.at(-1)?.data["atMs"]);
1960
- if (!Number.isFinite(cutoff)) continue;
1961
- const result = await this.store.deleteWhere.mutate({
1962
- collection: LOAD_SERIES_COLLECTION,
1963
- filter: {
1964
- where: { nodeId },
1965
- whereBetween: { atMs: [0, cutoff] }
1966
- }
1967
- });
1968
- deleted += result.deleted;
1969
- remaining -= result.deleted;
1970
- } catch (err) {
1971
- this.logger.warn("load series cap prune failed — the cap is not enforced this pass", { meta: {
1972
- nodeId,
1973
- error: errMsg(err)
1974
- } });
1975
- }
1976
- }
2235
+ };
2236
+ const some = parseLine("some");
2237
+ if (!some) return null;
1977
2238
  return {
1978
- deleted,
1979
- examined
2239
+ some,
2240
+ full: parseLine("full")
1980
2241
  };
2242
+ } catch {
2243
+ return null;
1981
2244
  }
1982
- };
1983
- /** Structural validation on read. A malformed row is skipped, never charted. */
1984
- function recordToRow(data) {
1985
- const nodeId = data["nodeId"];
1986
- const classification = data["classification"];
1987
- if (typeof nodeId !== "string" || typeof classification !== "string") return null;
1988
- const atMs = Number(data["atMs"]);
1989
- const pid = Number(data["pid"]);
1990
- const cpuDeci = Number(data["cpuDeci"]);
1991
- const rssMib = Number(data["rssMib"]);
2245
+ }
2246
+ function execAsync(cmd, args, timeoutMs = 5e3) {
2247
+ return new Promise((resolve, reject) => {
2248
+ execFile(cmd, args, { timeout: timeoutMs }, (err, stdout) => {
2249
+ if (err) return reject(err);
2250
+ resolve(stdout);
2251
+ });
2252
+ });
2253
+ }
2254
+ /**
2255
+ * Parse one `/proc/<pid>/stat` line. `null` for a line that does not parse —
2256
+ * a process that exited between the readdir and the read leaves a truncated
2257
+ * or empty file, and that is normal, not an error.
2258
+ */
2259
+ function parseProcStat(line) {
2260
+ const close = line.lastIndexOf(")");
2261
+ const open = line.indexOf("(");
2262
+ if (close < 0 || open < 0 || close < open) return null;
2263
+ const pid = Number(line.slice(0, open).trim());
2264
+ const comm = line.slice(open + 1, close);
2265
+ const rest = line.slice(close + 1).trim().split(/\s+/);
2266
+ const state = rest[0];
2267
+ const ppid = Number(rest[1]);
2268
+ const pgid = Number(rest[2]);
2269
+ const utime = Number(rest[11]);
2270
+ const stime = Number(rest[12]);
2271
+ const startTicks = Number(rest[19]);
2272
+ const rssPages = Number(rest[21]);
2273
+ if (state === void 0 || state === "") return null;
1992
2274
  if (![
1993
- atMs,
1994
2275
  pid,
1995
- cpuDeci,
1996
- rssMib
1997
- ].every((n) => Number.isFinite(n))) return null;
1998
- const rawAddon = data["addonId"];
1999
- const optional = (raw) => {
2000
- if (raw === null || raw === void 0) return null;
2001
- const n = Number(raw);
2002
- return Number.isFinite(n) ? n : null;
2003
- };
2276
+ ppid,
2277
+ pgid,
2278
+ utime,
2279
+ stime,
2280
+ startTicks,
2281
+ rssPages
2282
+ ].every(Number.isFinite)) return null;
2004
2283
  return {
2005
- nodeId,
2006
- atMs,
2007
2284
  pid,
2008
- addonId: typeof rawAddon === "string" ? rawAddon : null,
2009
- classification,
2010
- cpuDeci,
2011
- rssMib,
2012
- cpuMainDeci: optional(data["cpuMainDeci"]),
2013
- cpuGcDeci: optional(data["cpuGcDeci"])
2285
+ comm,
2286
+ state,
2287
+ ppid,
2288
+ pgid,
2289
+ ticks: utime + stime,
2290
+ startTicks,
2291
+ rssPages
2014
2292
  };
2015
2293
  }
2016
- //#endregion
2017
- //#region src/builtins/native-metrics/load-series-config.ts
2294
+ function statusKb(status, key) {
2295
+ const match = status.match(new RegExp(`^${key}:\\s+(\\d+)`, "m"));
2296
+ return match ? parseInt(match[1], 10) * 1024 : void 0;
2297
+ }
2298
+ /** Read `VmRSS` / `RssAnon` / `RssFile` (kB in the file) out of `/proc/<pid>/status`. */
2299
+ function parseProcStatusRss(status) {
2300
+ return {
2301
+ rssBytes: statusKb(status, "VmRSS"),
2302
+ privateBytes: statusKb(status, "RssAnon"),
2303
+ sharedBytes: statusKb(status, "RssFile")
2304
+ };
2305
+ }
2306
+ /** Seconds the process has been alive — `ps etime`, whole seconds. */
2307
+ function elapsedSecSince(input) {
2308
+ return Math.max(0, Math.floor(input.uptimeSec - input.startTicks / 100));
2309
+ }
2018
2310
  /**
2019
- * The two knobs of the durable load series, and the arithmetic that makes them
2020
- * honest.
2311
+ * `ps pcpu`: CPU time over elapsed time, as a percentage with one decimal.
2021
2312
  *
2022
- * ## Where they live, and why there
2023
- *
2024
- * In `native-metrics`'s OWN global settings document — the one that already
2025
- * exists and already carries `samplingIntervalMs`. Fields on a document, never
2026
- * a method per knob: `system.getLoggingSettings` / `setLoggingSettings` set
2027
- * that precedent and it holds here.
2028
- *
2029
- * They are NOT in the logging document, and that is a decision rather than an
2030
- * omission. That document is about levels and diagnostic windows; a metrics
2031
- * cadence and a table's retention are neither. Two documents that both claim a
2032
- * knob is how this repo has already shipped a switch nobody read.
2033
- *
2034
- * They are cluster-wide, not per-node. Every node emits on the same cadence or
2035
- * the fleet's series cannot be laid over each other, and the table is single
2036
- * and hub-resident, so a per-node retention would be a promise nothing could
2037
- * keep.
2038
- *
2039
- * ## Refused, not clamped
2040
- *
2041
- * A cadence outside 5-60 s is REFUSED. Clamping would silently store 10 when
2042
- * the operator typed 2 and then report 10 back as though it had been accepted
2043
- * — the failure mode where a knob and its readback agree on a value nobody
2044
- * chose. The schema advertises the bounds; this refuses anything else.
2313
+ * This is deliberately the LIFETIME average `ps` prints, not an instantaneous
2314
+ * rate: the columns fed from it have always shown that quantity, and the
2315
+ * instantaneous split is a different number with a different owner
2316
+ * (`cpuMainPercent` / `cpuGcPercent` from `thread-cpu-sampler.ts`). A process
2317
+ * with no elapsed window yet reports 0, never a division by zero.
2045
2318
  */
2046
- /** Why a value was refused — the message an operator is shown verbatim. */
2047
- var LoadSeriesConfigError = class extends Error {
2048
- constructor(message) {
2049
- super(message);
2050
- this.name = "LoadSeriesConfigError";
2319
+ function lifetimeCpuPercent(input) {
2320
+ const elapsedSec = input.uptimeSec - input.startTicks / 100;
2321
+ if (!(elapsedSec > 0)) return 0;
2322
+ const cpuSec = input.ticks / 100;
2323
+ return Math.round(cpuSec / elapsedSec * 1e3) / 10;
2324
+ }
2325
+ var PID_RE = /^\d+$/;
2326
+ var nodeProcReader = {
2327
+ listPids: async () => (await readdir("/proc")).filter((entry) => PID_RE.test(entry)).map((entry) => Number(entry)),
2328
+ readStat: (pid) => readFile(`/proc/${pid}/stat`, "latin1"),
2329
+ readStatus: (pid) => readFile(`/proc/${pid}/status`, "latin1"),
2330
+ readCmdline: (pid) => readFile(`/proc/${pid}/cmdline`, "latin1"),
2331
+ readUptimeSec: async () => {
2332
+ const [first] = (await readFile("/proc/uptime", "latin1")).trim().split(/\s+/);
2333
+ const seconds = Number(first);
2334
+ if (!Number.isFinite(seconds)) throw new Error(`/proc/uptime unreadable: ${String(first)}`);
2335
+ return seconds;
2051
2336
  }
2052
2337
  };
2053
- function requireInteger(value, field) {
2054
- const n = typeof value === "number" ? value : Number(value);
2055
- if (!Number.isFinite(n)) throw new LoadSeriesConfigError(`${field} must be a number, received ${String(value)}`);
2056
- return Math.round(n);
2338
+ /** `ps command=`: argv joined by spaces, or `[comm]` when there is no argv. */
2339
+ function commandFromCmdline(cmdline, comm) {
2340
+ const joined = cmdline.split("\0").filter((part) => part.length > 0).join(" ");
2341
+ return joined.length > 0 ? joined : `[${comm}]`;
2057
2342
  }
2343
+ //#endregion
2344
+ //#region src/builtins/native-metrics/proc-process-table.ts
2058
2345
  /**
2059
- * Resolve the configuration, REFUSING anything out of range.
2060
- *
2061
- * An ABSENT field takes its default — that is not a refusal, it is a document
2062
- * that has never been written. A PRESENT field outside its bounds throws.
2346
+ * The node's process table — every row `ps -eo pid,ppid,pgid,pcpu,rss,etime,
2347
+ * command` printed — read from `/proc` on Linux, with no fork.
2348
+ *
2349
+ * Why the fork mattered, and the numbers, are in `process/proc-stat.ts`. What
2350
+ * this file adds is the WHOLE-TABLE read: one `readdir` of `/proc`, then per
2351
+ * pid `stat`, `status` and `cmdline`, all through `fs/promises` so the reads
2352
+ * land on the libuv pool and the main thread pays only the parse. Measured in
2353
+ * the hub container (70 processes, D452): 2–4 ms wall for the walk, against
2354
+ * 3–4 ms for `ps` itself — the fork that USED to precede it was the cost.
2355
+ *
2356
+ * A pid that vanishes between the listing and its reads is omitted whole.
2357
+ * Never a partial row: a row with a command and no memory would render as a
2358
+ * process using nothing, which is a different claim from "gone".
2063
2359
  */
2064
- function resolveLoadSeriesConfig(raw) {
2065
- let cadenceSec = 10;
2066
- if (raw.loadSeriesCadenceSec !== void 0 && raw.loadSeriesCadenceSec !== null) {
2067
- cadenceSec = requireInteger(raw.loadSeriesCadenceSec, "loadSeriesCadenceSec");
2068
- if (cadenceSec < 5 || cadenceSec > 60) throw new LoadSeriesConfigError(`load series cadence must be between 5 and 60 seconds — refused ${cadenceSec}`);
2069
- }
2070
- let retentionHours = 6;
2071
- if (raw.loadSeriesRetentionHours !== void 0 && raw.loadSeriesRetentionHours !== null) {
2072
- retentionHours = requireInteger(raw.loadSeriesRetentionHours, "loadSeriesRetentionHours");
2073
- if (retentionHours < 1 || retentionHours > 72) throw new LoadSeriesConfigError(`load series retention must be between 1 and 72 hours — refused ${retentionHours}`);
2360
+ var execFileAsync = promisify(execFile);
2361
+ async function readProcRow(pid, uptimeSec, reader) {
2362
+ let stat;
2363
+ let cmdline;
2364
+ let status;
2365
+ try {
2366
+ [stat, cmdline, status] = await Promise.all([
2367
+ reader.readStat(pid),
2368
+ reader.readCmdline(pid),
2369
+ reader.readStatus(pid)
2370
+ ]);
2371
+ } catch {
2372
+ return null;
2074
2373
  }
2374
+ const parsed = parseProcStat(stat);
2375
+ if (parsed === null) return null;
2376
+ const rss = parseProcStatusRss(status);
2075
2377
  return {
2076
- cadenceSec,
2077
- retentionHours,
2078
- maxRows: DEFAULT_MAX_ROWS
2079
- };
2080
- }
2081
- /**
2082
- * Project the cost of a configuration from the numbers the cluster is actually
2083
- * reporting — nodes and processes OBSERVED, never a constant.
2084
- *
2085
- * This is what makes the knob honest: an operator raising retention to three
2086
- * days sees the row count and the megabytes BEFORE applying it, rather than
2087
- * discovering them afterwards. `MEASURED_BYTES_PER_ROW` is measured, not
2088
- * guessed — see `load-series-store.ts`.
2089
- */
2090
- function projectLoadSeriesCost(input) {
2091
- const samplesPerNodeWindow = input.config.retentionHours * 3600 / input.config.cadenceSec;
2092
- const intendedRows = Math.round(samplesPerNodeWindow * Math.max(input.observedProcessRows, 0));
2093
- const boundedRows = Math.min(intendedRows, input.config.maxRows);
2094
- return {
2095
- intendedRows,
2096
- boundedRows,
2097
- estimatedBytes: boundedRows * 87,
2098
- capBites: intendedRows > input.config.maxRows
2378
+ pid: parsed.pid,
2379
+ ppid: parsed.ppid,
2380
+ pgid: parsed.pgid,
2381
+ cpuPercent: lifetimeCpuPercent({
2382
+ ticks: parsed.ticks,
2383
+ startTicks: parsed.startTicks,
2384
+ uptimeSec
2385
+ }),
2386
+ memoryRssBytes: rss.rssBytes ?? 0,
2387
+ uptimeSec: elapsedSecSince({
2388
+ startTicks: parsed.startTicks,
2389
+ uptimeSec
2390
+ }),
2391
+ command: commandFromCmdline(cmdline, parsed.comm)
2099
2392
  };
2100
2393
  }
2101
- /** Human-readable one-liner for the settings form. */
2102
- function describeLoadSeriesCost(projection) {
2103
- const mib = (projection.estimatedBytes / 1048576).toFixed(1);
2104
- const rows = projection.intendedRows.toLocaleString("en-US");
2105
- const capped = projection.boundedRows.toLocaleString("en-US");
2106
- if (!projection.capBites) return `This configuration retains ~${rows} rows ≈ ${mib} MiB on the hub database (NVMe cache), measured at 87 bytes per row.`;
2107
- return `This configuration WANTS ~${rows} rows, above the ${projection.boundedRows.toLocaleString("en-US")}-row hard cap. The table will hold ~${capped} rows ≈ ${mib} MiB and the OLDEST samples will be evicted, so the effective window is shorter than the retention you asked for.`;
2394
+ /** Every process in `/proc`, as the rows `ps -eo …` would have printed. */
2395
+ async function readProcProcessTable(reader = nodeProcReader) {
2396
+ const [pids, uptimeSec] = await Promise.all([reader.listPids(), reader.readUptimeSec()]);
2397
+ return (await Promise.all(pids.map((pid) => readProcRow(pid, uptimeSec, reader)))).filter((row) => row !== null);
2108
2398
  }
2109
- //#endregion
2110
- //#region src/builtins/native-metrics/load-series-reader.ts
2111
- /**
2112
- * The ONE reader over both retention tiers.
2113
- *
2114
- * The hot window is the in-memory ring; the cold one is
2115
- * `metrics:node-load-samples`. A caller never learns which tier a point came
2116
- * from, and that is a hard requirement rather than a nicety: a second reader
2117
- * is how two charts start disagreeing about the same node, and it is how "the
2118
- * ring says X, the table says Y" becomes a question nobody can answer.
2119
- *
2120
- * Three things happen here, in this order, and each one is load-bearing:
2121
- *
2122
- * 1. **Merge and dedupe on `atMs`.** The two tiers OVERLAP by construction —
2123
- * the ring holds the last 180 snapshots and the table holds every snapshot
2124
- * the ring accepted, including those same 180. A snapshot present in both
2125
- * must contribute exactly once. Identity is `atMs`, because one node emits
2126
- * one snapshot per tick and a timestamp already held IS that snapshot. The
2127
- * HOT copy wins on a tie: it is the payload as it arrived, while the cold
2128
- * one has been through the storage encode.
2129
- * 2. **Fold per function.** The chart draws one series per addon, so the fold
2130
- * happens here rather than in the browser.
2131
- * 3. **Reduce, preserving the extremes.** See `load-series-fold.ts` for why
2132
- * the representative of a bucket is its MAX and never its mean.
2133
- *
2134
- * A GAP stays a gap through all three. Emission is fixed-cadence, so a missing
2135
- * interval means one thing — nobody reported — and nothing here fills it in.
2136
- */
2137
2399
  /**
2138
- * Merge the two tiers into one deduped, oldest-first list of samples.
2139
- *
2140
- * Exported because the dedup is the property worth testing on its own: a
2141
- * doubled point is invisible in a total and obvious only as a chart that reads
2142
- * twice as loud as the machine.
2400
+ * Parse `ps etime` → seconds. `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`.
2401
+ * Only the `ps` path needs this; `/proc` gives the number directly.
2143
2402
  */
2144
- function mergeSamples(cold, hot) {
2145
- const byAt = /* @__PURE__ */ new Map();
2146
- for (const sample of cold) byAt.set(sample.atMs, sample);
2147
- for (const sample of hot) byAt.set(sample.atMs, sample);
2148
- return [...byAt.values()].toSorted((a, b) => a.atMs - b.atMs);
2403
+ function parseEtime(etime) {
2404
+ const dashParts = etime.split("-");
2405
+ let days = 0;
2406
+ let hms = etime;
2407
+ if (dashParts.length === 2) {
2408
+ days = parseInt(dashParts[0], 10) || 0;
2409
+ hms = dashParts[1];
2410
+ }
2411
+ const parts = hms.split(":").map((p) => parseInt(p, 10) || 0);
2412
+ let hours = 0;
2413
+ let minutes = 0;
2414
+ let seconds = 0;
2415
+ if (parts.length === 3) [hours, minutes, seconds] = parts;
2416
+ else if (parts.length === 2) [minutes, seconds] = parts;
2417
+ else if (parts.length === 1) [seconds] = parts;
2418
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
2149
2419
  }
2150
- /** `RetainedProcess` is already the fold's row shape — named, not re-derived. */
2151
- function toFoldRows(processes) {
2152
- return processes;
2420
+ var PS_ROW_RE = /^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/;
2421
+ /** Parse the output of `ps -eo pid=,ppid=,pgid=,pcpu=,rss=,etime=,command=`. */
2422
+ function parsePsTable(stdout) {
2423
+ const rows = [];
2424
+ for (const line of stdout.split("\n")) {
2425
+ const match = line.trim().match(PS_ROW_RE);
2426
+ if (!match) continue;
2427
+ const [, pidS, ppidS, pgidS, cpuS, rssS, etimeS, command] = match;
2428
+ rows.push({
2429
+ pid: parseInt(pidS, 10),
2430
+ ppid: parseInt(ppidS, 10),
2431
+ pgid: parseInt(pgidS, 10),
2432
+ cpuPercent: Math.round(parseFloat(cpuS) * 10) / 10,
2433
+ memoryRssBytes: parseInt(rssS, 10) * 1024,
2434
+ uptimeSec: parseEtime(etimeS),
2435
+ command
2436
+ });
2437
+ }
2438
+ return rows;
2439
+ }
2440
+ /** The fork. Only reached on a platform without `/proc`. */
2441
+ async function spawnPsTable() {
2442
+ const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,pgid=,pcpu=,rss=,etime=,command="], {
2443
+ timeout: 5e3,
2444
+ maxBuffer: 8 * 1024 * 1024
2445
+ });
2446
+ return parsePsTable(stdout);
2153
2447
  }
2448
+ var defaultDeps = {
2449
+ platform: platform(),
2450
+ reader: nodeProcReader,
2451
+ spawnPs: spawnPsTable
2452
+ };
2154
2453
  /**
2155
- * Merge, fold and reduce. The whole read path in one pure function, so every
2156
- * property that matters — dedup, peak survival, an empty bucket staying empty
2157
- * — is testable without a database or a ring.
2454
+ * The process table, from `/proc` where it exists and from `ps` elsewhere.
2455
+ * Throws only when the chosen source cannot answer at all; the caller logs it
2456
+ * as a skipped interval.
2158
2457
  */
2159
- function mergeLoadSeries(input) {
2160
- const samples = mergeSamples(input.cold, input.hot);
2161
- if (samples.length === 0) return {
2162
- series: [],
2163
- bucketMs: input.cadenceMs,
2164
- retainedSamples: 0,
2165
- oldestAtMs: null
2166
- };
2167
- const byKey = /* @__PURE__ */ new Map();
2168
- for (const sample of samples) for (const bucket of foldSnapshotByFunction(toFoldRows(sample.processes), sample.atMs)) {
2169
- const held = byKey.get(bucket.key);
2170
- if (held === void 0) byKey.set(bucket.key, {
2171
- kind: bucket.kind,
2172
- points: [bucket.point]
2173
- });
2174
- else held.points.push(bucket.point);
2175
- }
2176
- const oldestAtMs = samples[0]?.atMs ?? null;
2177
- const newestAtMs = samples.at(-1)?.atMs ?? oldestAtMs;
2178
- const spanMs = oldestAtMs === null || newestAtMs === null ? 0 : newestAtMs - oldestAtMs + input.cadenceMs;
2179
- const bucketMs = input.maxPoints === void 0 ? input.cadenceMs : resolveBucketMs(spanMs, input.cadenceMs, input.maxPoints);
2180
- const origin = oldestAtMs ?? 0;
2181
- const series = [...byKey.entries()].map(([key, held]) => ({
2182
- key,
2183
- kind: held.kind,
2184
- points: bucketMs > input.cadenceMs ? reducePoints(held.points, bucketMs, origin) : held.points
2185
- }));
2186
- const weight = (s) => {
2187
- const last = s.points.at(-1);
2188
- if (last === void 0) return -1;
2189
- if (last.cpuMainPercent !== null && last.cpuGcPercent !== null) return last.cpuMainPercent + last.cpuGcPercent;
2190
- return last.cpuLifetimePercent;
2191
- };
2192
- return {
2193
- series: series.toSorted((a, b) => {
2194
- const d = weight(b) - weight(a);
2195
- return d !== 0 ? d : a.key.localeCompare(b.key);
2196
- }),
2197
- bucketMs,
2198
- retainedSamples: samples.length,
2199
- oldestAtMs
2200
- };
2458
+ function readProcessTable(deps = defaultDeps) {
2459
+ return deps.platform === "linux" ? readProcProcessTable(deps.reader) : deps.spawnPs();
2201
2460
  }
2202
2461
  //#endregion
2203
2462
  //#region src/builtins/native-metrics/process-classification.ts
@@ -2448,7 +2707,6 @@ var ThreadCpuTracker = class {
2448
2707
  };
2449
2708
  //#endregion
2450
2709
  //#region src/builtins/native-metrics/native-metrics.addon.ts
2451
- var execFileAsync = promisify(execFile);
2452
2710
  /**
2453
2711
  * Cadence for the per-node metric snapshot bus events. ~5 s matches
2454
2712
  * the existing UI polling cadence (ProcessesTab, ProcessResources)
@@ -2457,25 +2715,13 @@ var execFileAsync = promisify(execFile);
2457
2715
  */
2458
2716
  var METRICS_SNAPSHOT_INTERVAL_MS = 5e3;
2459
2717
  /**
2460
- * The process-tree snapshot is FIXED-CADENCE, and its cadence is the
2461
- * operator's `loadSeriesCadenceSec` (5-60 s, 10 by default).
2462
- *
2463
- * It used to emit only ON A CHANGE, with a 60 s fallback heartbeat, and the
2464
- * dedup is gone rather than tuned. A conditional emit makes a hole in the
2465
- * series mean two different things — "nothing changed" and "nobody reported"
2466
- * — and telling those apart is the entire reason an operator opens this chart
2467
- * after a spike. Now a gap means exactly one thing.
2468
- *
2469
- * It is not free: the emit rate is deterministic, so the durable table's row
2470
- * count is too (nodes x processes / cadence, no discount for a quiet fleet).
2471
- * The measured cost of that is in `docs/architecture/load-ledger.md`, and it
2472
- * is why the retention default is six hours rather than three days.
2473
- */
2474
- /**
2475
- * Force a metrics-snapshot emit at least every 60s even when the
2718
+ * Force a RESOURCES-snapshot emit at least every 60s even when the
2476
2719
  * coarsened payload looks unchanged. Without this an idle node
2477
2720
  * (steady CPU within ±5%, no process churn) goes silent on the
2478
2721
  * bus and the UI's "node alive" chip can stale out.
2722
+ *
2723
+ * The PROCESS-tree snapshot has no such heartbeat and no dedup: it is
2724
+ * fixed-cadence — see `startProcessSnapshotTimer`.
2479
2725
  */
2480
2726
  var METRICS_SNAPSHOT_HEARTBEAT_MS = 6e4;
2481
2727
  /**
@@ -2790,7 +3036,23 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
2790
3036
  } });
2791
3037
  }
2792
3038
  }
2793
- /** (Re)arm the fixed-cadence process-tree timer at the configured interval. */
3039
+ /**
3040
+ * (Re)arm the process-tree timer at the configured interval.
3041
+ *
3042
+ * The process-tree snapshot is FIXED-CADENCE, and its cadence is the
3043
+ * operator's `loadSeriesCadenceSec` (5-60 s, 10 by default).
3044
+ *
3045
+ * It used to emit only ON A CHANGE, with a 60 s fallback heartbeat, and the
3046
+ * dedup is gone rather than tuned. A conditional emit makes a hole in the
3047
+ * series mean two different things — "nothing changed" and "nobody reported"
3048
+ * — and telling those apart is the entire reason an operator opens this chart
3049
+ * after a spike. Now a gap means exactly one thing.
3050
+ *
3051
+ * It is not free: the emit rate is deterministic, so the durable table's row
3052
+ * count is too (nodes x processes / cadence, no discount for a quiet fleet).
3053
+ * The measured cost of that is in `docs/architecture/load-ledger.md`, and it
3054
+ * is why the retention default is six hours rather than three days.
3055
+ */
2794
3056
  startProcessSnapshotTimer() {
2795
3057
  if (this.processSnapshotTimer) clearInterval(this.processSnapshotTimer);
2796
3058
  this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), this.loadConfig.cadenceSec * 1e3);
@@ -2852,7 +3114,8 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
2852
3114
  /**
2853
3115
  * Emit one `metrics.node-processes-snapshot` for this node.
2854
3116
  *
2855
- * Heavy — a full OS `ps -eo` scan plus a `$process.list` broker call — and
3117
+ * Heavy — a full process-table read (`/proc` on Linux, `ps -eo` elsewhere;
3118
+ * `proc-process-table.ts`) plus a `$process.list` broker call — and
2856
3119
  * UNCONDITIONAL. The change-detection that used to guard it is gone: with a
2857
3120
  * fixed cadence a missing interval means exactly one thing, and that is the
2858
3121
  * property an operator investigating a spike is actually looking for.
@@ -2956,34 +3219,18 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
2956
3219
  cpuSplits
2957
3220
  });
2958
3221
  }
2959
- /** Raw `ps` scan returning every pid + command + resource stats. */
3222
+ /**
3223
+ * Every pid + command + resource stats on this node.
3224
+ *
3225
+ * From `/proc` on Linux — no fork. This used to be a `ps -eo` child on every
3226
+ * tick, i.e. a synchronous `fork()` of hub-main at p50 91 ms (D441, D452).
3227
+ * `ps` remains the source only on a platform without `/proc`.
3228
+ */
2960
3229
  async runPs() {
2961
3230
  try {
2962
- const { stdout } = await execFileAsync("ps", ["-eo", "pid=,ppid=,pgid=,pcpu=,rss=,etime=,command="], {
2963
- timeout: 5e3,
2964
- maxBuffer: 8 * 1024 * 1024
2965
- });
2966
- const lines = stdout.split("\n");
2967
- const rows = [];
2968
- for (const line of lines) {
2969
- const trimmed = line.trim();
2970
- if (!trimmed) continue;
2971
- const match = trimmed.match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.+)$/);
2972
- if (!match) continue;
2973
- const [, pidS, ppidS, pgidS, cpuS, rssS, etimeS, command] = match;
2974
- rows.push({
2975
- pid: parseInt(pidS, 10),
2976
- ppid: parseInt(ppidS, 10),
2977
- pgid: parseInt(pgidS, 10),
2978
- cpuPercent: Math.round(parseFloat(cpuS) * 10) / 10,
2979
- memoryRssBytes: parseInt(rssS, 10) * 1024,
2980
- uptimeSec: parseEtime(etimeS),
2981
- command
2982
- });
2983
- }
2984
- return rows;
3231
+ return await readProcessTable();
2985
3232
  } catch (err) {
2986
- this.ctx.logger.warn("ps scan failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
3233
+ this.ctx.logger.warn("process table read failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
2987
3234
  return [];
2988
3235
  }
2989
3236
  }
@@ -3068,28 +3315,5 @@ var NativeMetricsAddon = class NativeMetricsAddon extends BaseAddon {
3068
3315
  }] });
3069
3316
  }
3070
3317
  };
3071
- /**
3072
- * Parse `ps etime` format → seconds.
3073
- *
3074
- * Formats we handle:
3075
- * `MM:SS` (< 1h)
3076
- * `HH:MM:SS` (< 1d)
3077
- * `DD-HH:MM:SS` (>= 1d)
3078
- */
3079
- function parseEtime(etime) {
3080
- const dashParts = etime.split("-");
3081
- let days = 0;
3082
- let hms = etime;
3083
- if (dashParts.length === 2) {
3084
- days = parseInt(dashParts[0], 10) || 0;
3085
- hms = dashParts[1];
3086
- }
3087
- const parts = hms.split(":").map((p) => parseInt(p, 10) || 0);
3088
- let hours = 0, minutes = 0, seconds = 0;
3089
- if (parts.length === 3) [hours, minutes, seconds] = parts;
3090
- else if (parts.length === 2) [minutes, seconds] = parts;
3091
- else if (parts.length === 1) [seconds] = parts;
3092
- return days * 86400 + hours * 3600 + minutes * 60 + seconds;
3093
- }
3094
3318
  //#endregion
3095
- export { NativeMetricsAddon as default, NativeMetricsProvider as t };
3319
+ export { NativeMetricsProvider as a, NativeMetricsAddon as default, parseProcStatusRss as i, nodeProcReader as n, parseProcStat as r, lifetimeCpuPercent as t };