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