viberag 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getRunDir } from '../lib/constants.js';
4
+ const DEFAULT_POLL_INTERVAL_MS = 3000;
5
+ const DEFAULT_THRESHOLD_BYTES = 4 * 1024 * 1024 * 1024;
6
+ const DEFAULT_RECOVERY_BYTES = 3.5 * 1024 * 1024 * 1024;
7
+ const DEFAULT_GROWTH_THRESHOLD_BYTES = 1 * 1024 * 1024 * 1024;
8
+ const DEFAULT_GROWTH_WINDOW_MS = 10000;
9
+ const DEFAULT_MIN_REPORT_INTERVAL_MS = 10000;
10
+ const DEFAULT_MAX_REPORTS_PER_DAY = 20;
11
+ const DEFAULT_SAMPLE_RETENTION_MS = 10 * 60 * 1000;
12
+ const DEFAULT_STATE_FILE_NAME = 'memory-monitor-state.json';
13
+ const MAX_SAMPLE_HISTORY = 1200;
14
+ class JsonFileMemoryMonitorStateStore {
15
+ constructor(projectRoot) {
16
+ Object.defineProperty(this, "stateFilePath", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ this.stateFilePath = path.join(getRunDir(projectRoot), DEFAULT_STATE_FILE_NAME);
23
+ }
24
+ async load() {
25
+ try {
26
+ const raw = await fs.readFile(this.stateFilePath, 'utf8');
27
+ const parsed = JSON.parse(raw);
28
+ if (typeof parsed !== 'object' || parsed === null) {
29
+ return null;
30
+ }
31
+ const maybe = parsed;
32
+ const dayKeyValue = typeof maybe['dayKey'] === 'string' ? maybe['dayKey'].trim() : '';
33
+ const sentCountValue = typeof maybe['sentCount'] === 'number' ? maybe['sentCount'] : -1;
34
+ const lastSentAtMsValue = typeof maybe['lastSentAtMs'] === 'number' ? maybe['lastSentAtMs'] : -1;
35
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dayKeyValue)) {
36
+ return null;
37
+ }
38
+ if (!Number.isFinite(sentCountValue) ||
39
+ sentCountValue < 0 ||
40
+ !Number.isFinite(lastSentAtMsValue) ||
41
+ lastSentAtMsValue < 0) {
42
+ return null;
43
+ }
44
+ return {
45
+ dayKey: dayKeyValue,
46
+ sentCount: Math.floor(sentCountValue),
47
+ lastSentAtMs: Math.floor(lastSentAtMsValue),
48
+ };
49
+ }
50
+ catch (error) {
51
+ if (error instanceof Error && 'code' in error) {
52
+ const code = error.code;
53
+ if (code === 'ENOENT') {
54
+ return null;
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ }
60
+ async save(state) {
61
+ const directory = path.dirname(this.stateFilePath);
62
+ const tempPath = `${this.stateFilePath}.tmp`;
63
+ await fs.mkdir(directory, { recursive: true });
64
+ await fs.writeFile(tempPath, `${JSON.stringify(state)}\n`, 'utf8');
65
+ await fs.rename(tempPath, this.stateFilePath);
66
+ }
67
+ }
68
+ function toMB(bytes) {
69
+ return Number((bytes / (1024 * 1024)).toFixed(1));
70
+ }
71
+ function isoAt(ms) {
72
+ return new Date(ms).toISOString();
73
+ }
74
+ function dayKeyAt(ms) {
75
+ return isoAt(ms).slice(0, 10);
76
+ }
77
+ function normalizeInteger(value, fallback) {
78
+ if (value === undefined)
79
+ return fallback;
80
+ if (!Number.isFinite(value))
81
+ return fallback;
82
+ if (value <= 0)
83
+ return fallback;
84
+ return Math.floor(value);
85
+ }
86
+ export class DaemonMemoryMonitor {
87
+ constructor(options) {
88
+ Object.defineProperty(this, "projectRoot", {
89
+ enumerable: true,
90
+ configurable: true,
91
+ writable: true,
92
+ value: void 0
93
+ });
94
+ Object.defineProperty(this, "onReport", {
95
+ enumerable: true,
96
+ configurable: true,
97
+ writable: true,
98
+ value: void 0
99
+ });
100
+ Object.defineProperty(this, "logger", {
101
+ enumerable: true,
102
+ configurable: true,
103
+ writable: true,
104
+ value: void 0
105
+ });
106
+ Object.defineProperty(this, "readMemoryUsage", {
107
+ enumerable: true,
108
+ configurable: true,
109
+ writable: true,
110
+ value: void 0
111
+ });
112
+ Object.defineProperty(this, "now", {
113
+ enumerable: true,
114
+ configurable: true,
115
+ writable: true,
116
+ value: void 0
117
+ });
118
+ Object.defineProperty(this, "pollIntervalMs", {
119
+ enumerable: true,
120
+ configurable: true,
121
+ writable: true,
122
+ value: void 0
123
+ });
124
+ Object.defineProperty(this, "thresholdBytes", {
125
+ enumerable: true,
126
+ configurable: true,
127
+ writable: true,
128
+ value: void 0
129
+ });
130
+ Object.defineProperty(this, "recoveryBytes", {
131
+ enumerable: true,
132
+ configurable: true,
133
+ writable: true,
134
+ value: void 0
135
+ });
136
+ Object.defineProperty(this, "growthThresholdBytes", {
137
+ enumerable: true,
138
+ configurable: true,
139
+ writable: true,
140
+ value: void 0
141
+ });
142
+ Object.defineProperty(this, "growthWindowMs", {
143
+ enumerable: true,
144
+ configurable: true,
145
+ writable: true,
146
+ value: void 0
147
+ });
148
+ Object.defineProperty(this, "minReportIntervalMs", {
149
+ enumerable: true,
150
+ configurable: true,
151
+ writable: true,
152
+ value: void 0
153
+ });
154
+ Object.defineProperty(this, "maxReportsPerDay", {
155
+ enumerable: true,
156
+ configurable: true,
157
+ writable: true,
158
+ value: void 0
159
+ });
160
+ Object.defineProperty(this, "sampleRetentionMs", {
161
+ enumerable: true,
162
+ configurable: true,
163
+ writable: true,
164
+ value: void 0
165
+ });
166
+ Object.defineProperty(this, "stateStore", {
167
+ enumerable: true,
168
+ configurable: true,
169
+ writable: true,
170
+ value: void 0
171
+ });
172
+ Object.defineProperty(this, "timer", {
173
+ enumerable: true,
174
+ configurable: true,
175
+ writable: true,
176
+ value: null
177
+ });
178
+ Object.defineProperty(this, "checkInFlight", {
179
+ enumerable: true,
180
+ configurable: true,
181
+ writable: true,
182
+ value: false
183
+ });
184
+ Object.defineProperty(this, "samples", {
185
+ enumerable: true,
186
+ configurable: true,
187
+ writable: true,
188
+ value: []
189
+ });
190
+ Object.defineProperty(this, "thresholdActive", {
191
+ enumerable: true,
192
+ configurable: true,
193
+ writable: true,
194
+ value: false
195
+ });
196
+ Object.defineProperty(this, "thresholdFirstTriggeredAtMs", {
197
+ enumerable: true,
198
+ configurable: true,
199
+ writable: true,
200
+ value: null
201
+ });
202
+ Object.defineProperty(this, "limiterState", {
203
+ enumerable: true,
204
+ configurable: true,
205
+ writable: true,
206
+ value: void 0
207
+ });
208
+ Object.defineProperty(this, "suppressedByCooldown", {
209
+ enumerable: true,
210
+ configurable: true,
211
+ writable: true,
212
+ value: 0
213
+ });
214
+ Object.defineProperty(this, "suppressedByDailyCap", {
215
+ enumerable: true,
216
+ configurable: true,
217
+ writable: true,
218
+ value: 0
219
+ });
220
+ Object.defineProperty(this, "stateLoaded", {
221
+ enumerable: true,
222
+ configurable: true,
223
+ writable: true,
224
+ value: false
225
+ });
226
+ this.projectRoot = options.projectRoot;
227
+ this.onReport = options.onReport;
228
+ this.logger = options.logger ?? null;
229
+ this.readMemoryUsage =
230
+ options.readMemoryUsage ?? (() => process.memoryUsage());
231
+ this.now = options.now ?? (() => Date.now());
232
+ this.pollIntervalMs = normalizeInteger(options.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS);
233
+ this.thresholdBytes = normalizeInteger(options.thresholdBytes, DEFAULT_THRESHOLD_BYTES);
234
+ this.recoveryBytes = normalizeInteger(options.recoveryBytes, DEFAULT_RECOVERY_BYTES);
235
+ this.growthThresholdBytes = normalizeInteger(options.growthThresholdBytes, DEFAULT_GROWTH_THRESHOLD_BYTES);
236
+ this.growthWindowMs = normalizeInteger(options.growthWindowMs, DEFAULT_GROWTH_WINDOW_MS);
237
+ this.minReportIntervalMs = normalizeInteger(options.minReportIntervalMs, DEFAULT_MIN_REPORT_INTERVAL_MS);
238
+ this.maxReportsPerDay = normalizeInteger(options.maxReportsPerDay, DEFAULT_MAX_REPORTS_PER_DAY);
239
+ this.sampleRetentionMs = normalizeInteger(options.sampleRetentionMs, DEFAULT_SAMPLE_RETENTION_MS);
240
+ this.stateStore =
241
+ options.stateStore ??
242
+ new JsonFileMemoryMonitorStateStore(this.projectRoot);
243
+ const now = this.now();
244
+ this.limiterState = {
245
+ dayKey: dayKeyAt(now),
246
+ sentCount: 0,
247
+ lastSentAtMs: 0,
248
+ };
249
+ }
250
+ async start() {
251
+ if (this.timer)
252
+ return;
253
+ await this.ensureLimiterStateLoaded();
254
+ await this.checkNow();
255
+ this.timer = setInterval(() => {
256
+ void this.checkNow();
257
+ }, this.pollIntervalMs);
258
+ this.timer.unref?.();
259
+ }
260
+ async stop() {
261
+ if (this.timer) {
262
+ clearInterval(this.timer);
263
+ this.timer = null;
264
+ }
265
+ await this.persistLimiterState();
266
+ this.thresholdActive = false;
267
+ this.thresholdFirstTriggeredAtMs = null;
268
+ }
269
+ async checkNow() {
270
+ if (this.checkInFlight)
271
+ return;
272
+ this.checkInFlight = true;
273
+ try {
274
+ await this.runCheck();
275
+ }
276
+ finally {
277
+ this.checkInFlight = false;
278
+ }
279
+ }
280
+ async runCheck() {
281
+ await this.ensureLimiterStateLoaded();
282
+ const now = this.now();
283
+ this.rollLimiterDayIfNeeded(now);
284
+ const usage = this.readMemoryUsage();
285
+ this.recordSample(now, usage);
286
+ if (this.thresholdActive && usage.rss <= this.recoveryBytes) {
287
+ this.thresholdActive = false;
288
+ this.thresholdFirstTriggeredAtMs = null;
289
+ }
290
+ const triggers = [];
291
+ if (usage.rss >= this.thresholdBytes) {
292
+ if (!this.thresholdActive) {
293
+ this.thresholdActive = true;
294
+ this.thresholdFirstTriggeredAtMs = now;
295
+ }
296
+ triggers.push({
297
+ kind: 'threshold',
298
+ rssMB: toMB(usage.rss),
299
+ thresholdMB: toMB(this.thresholdBytes),
300
+ firstTriggeredAt: this.thresholdFirstTriggeredAtMs === null
301
+ ? null
302
+ : isoAt(this.thresholdFirstTriggeredAtMs),
303
+ });
304
+ }
305
+ const growth = this.computeGrowth(now);
306
+ if (growth && growth.growthBytes >= this.growthThresholdBytes) {
307
+ triggers.push({
308
+ kind: 'rapid_growth',
309
+ growthMB: toMB(growth.growthBytes),
310
+ growthThresholdMB: toMB(this.growthThresholdBytes),
311
+ windowSec: Number((this.growthWindowMs / 1000).toFixed(1)),
312
+ durationSec: Number((growth.durationMs / 1000).toFixed(1)),
313
+ fromRssMB: toMB(growth.fromRssBytes),
314
+ toRssMB: toMB(growth.toRssBytes),
315
+ fromAt: isoAt(growth.fromAtMs),
316
+ toAt: isoAt(growth.toAtMs),
317
+ });
318
+ }
319
+ if (triggers.length === 0)
320
+ return;
321
+ const limiterBlock = this.getLimiterBlockReason(now);
322
+ if (limiterBlock === 'daily_cap') {
323
+ this.suppressedByDailyCap += 1;
324
+ return;
325
+ }
326
+ if (limiterBlock === 'cooldown') {
327
+ this.suppressedByCooldown += 1;
328
+ return;
329
+ }
330
+ this.limiterState.sentCount += 1;
331
+ this.limiterState.lastSentAtMs = now;
332
+ await this.persistLimiterState();
333
+ const report = this.buildReport(now, usage, triggers, growth);
334
+ try {
335
+ await this.onReport(report);
336
+ }
337
+ catch (error) {
338
+ const message = error instanceof Error ? error.message : String(error);
339
+ this.log('warn', `Memory monitor report callback failed: ${message}`);
340
+ }
341
+ }
342
+ async ensureLimiterStateLoaded() {
343
+ if (this.stateLoaded)
344
+ return;
345
+ this.stateLoaded = true;
346
+ try {
347
+ const loaded = await this.stateStore.load();
348
+ if (!loaded)
349
+ return;
350
+ this.limiterState = {
351
+ dayKey: loaded.dayKey,
352
+ sentCount: Math.max(0, Math.floor(loaded.sentCount)),
353
+ lastSentAtMs: Math.max(0, Math.floor(loaded.lastSentAtMs)),
354
+ };
355
+ }
356
+ catch (error) {
357
+ const message = error instanceof Error ? error.message : String(error);
358
+ this.log('warn', `Memory monitor state load failed: ${message}`);
359
+ }
360
+ }
361
+ async persistLimiterState() {
362
+ try {
363
+ await this.stateStore.save(this.limiterState);
364
+ }
365
+ catch (error) {
366
+ const message = error instanceof Error ? error.message : String(error);
367
+ this.log('warn', `Memory monitor state save failed: ${message}`);
368
+ }
369
+ }
370
+ rollLimiterDayIfNeeded(now) {
371
+ const dayKey = dayKeyAt(now);
372
+ if (dayKey === this.limiterState.dayKey)
373
+ return;
374
+ this.limiterState = {
375
+ dayKey,
376
+ sentCount: 0,
377
+ lastSentAtMs: 0,
378
+ };
379
+ this.suppressedByCooldown = 0;
380
+ this.suppressedByDailyCap = 0;
381
+ }
382
+ getLimiterBlockReason(now) {
383
+ if (this.limiterState.sentCount >= this.maxReportsPerDay) {
384
+ return 'daily_cap';
385
+ }
386
+ if (this.limiterState.lastSentAtMs > 0 &&
387
+ now - this.limiterState.lastSentAtMs < this.minReportIntervalMs) {
388
+ return 'cooldown';
389
+ }
390
+ return null;
391
+ }
392
+ recordSample(now, usage) {
393
+ this.samples.push({
394
+ atMs: now,
395
+ rssBytes: usage.rss,
396
+ heapUsedBytes: usage.heapUsed,
397
+ heapTotalBytes: usage.heapTotal,
398
+ externalBytes: usage.external,
399
+ arrayBuffersBytes: usage.arrayBuffers,
400
+ });
401
+ const cutoff = now - this.sampleRetentionMs;
402
+ while (this.samples.length > 0 && this.samples[0].atMs < cutoff) {
403
+ this.samples.shift();
404
+ }
405
+ while (this.samples.length > MAX_SAMPLE_HISTORY) {
406
+ this.samples.shift();
407
+ }
408
+ }
409
+ computeGrowth(now) {
410
+ const cutoff = now - this.growthWindowMs;
411
+ const windowSamples = this.samples.filter(sample => sample.atMs >= cutoff);
412
+ if (windowSamples.length < 2)
413
+ return null;
414
+ const newest = windowSamples[windowSamples.length - 1];
415
+ let minSample = windowSamples[0];
416
+ let minRssBytes = minSample.rssBytes;
417
+ let maxRssBytes = minSample.rssBytes;
418
+ for (const sample of windowSamples) {
419
+ if (sample.rssBytes < minSample.rssBytes) {
420
+ minSample = sample;
421
+ }
422
+ if (sample.rssBytes < minRssBytes) {
423
+ minRssBytes = sample.rssBytes;
424
+ }
425
+ if (sample.rssBytes > maxRssBytes) {
426
+ maxRssBytes = sample.rssBytes;
427
+ }
428
+ }
429
+ const growthBytes = Math.max(0, newest.rssBytes - minSample.rssBytes);
430
+ const durationMs = Math.max(1, newest.atMs - minSample.atMs);
431
+ return {
432
+ sampleCount: windowSamples.length,
433
+ fromAtMs: minSample.atMs,
434
+ toAtMs: newest.atMs,
435
+ fromRssBytes: minSample.rssBytes,
436
+ toRssBytes: newest.rssBytes,
437
+ minRssBytes,
438
+ maxRssBytes,
439
+ growthBytes,
440
+ durationMs,
441
+ };
442
+ }
443
+ buildReport(now, usage, triggers, growth) {
444
+ const recent = this.samples.slice(-8).map(sample => ({
445
+ at: isoAt(sample.atMs),
446
+ rssMB: toMB(sample.rssBytes),
447
+ heapUsedMB: toMB(sample.heapUsedBytes),
448
+ externalMB: toMB(sample.externalBytes),
449
+ }));
450
+ return {
451
+ observedAt: isoAt(now),
452
+ observedAtMs: now,
453
+ usage,
454
+ triggers,
455
+ threshold: {
456
+ active: this.thresholdActive,
457
+ thresholdMB: toMB(this.thresholdBytes),
458
+ recoveryMB: toMB(this.recoveryBytes),
459
+ firstTriggeredAt: this.thresholdFirstTriggeredAtMs === null
460
+ ? null
461
+ : isoAt(this.thresholdFirstTriggeredAtMs),
462
+ },
463
+ growth: growth
464
+ ? {
465
+ windowSec: Number((this.growthWindowMs / 1000).toFixed(1)),
466
+ sampleCount: growth.sampleCount,
467
+ fromAt: isoAt(growth.fromAtMs),
468
+ toAt: isoAt(growth.toAtMs),
469
+ fromRssMB: toMB(growth.fromRssBytes),
470
+ toRssMB: toMB(growth.toRssBytes),
471
+ minRssMB: toMB(growth.minRssBytes),
472
+ maxRssMB: toMB(growth.maxRssBytes),
473
+ growthMB: toMB(growth.growthBytes),
474
+ durationSec: Number((growth.durationMs / 1000).toFixed(1)),
475
+ }
476
+ : null,
477
+ rateLimit: {
478
+ dayKey: this.limiterState.dayKey,
479
+ sentToday: this.limiterState.sentCount,
480
+ maxReportsPerDay: this.maxReportsPerDay,
481
+ minReportIntervalSec: Number((this.minReportIntervalMs / 1000).toFixed(1)),
482
+ lastReportedAt: this.limiterState.lastSentAtMs > 0
483
+ ? isoAt(this.limiterState.lastSentAtMs)
484
+ : null,
485
+ suppressedByCooldown: this.suppressedByCooldown,
486
+ suppressedByDailyCap: this.suppressedByDailyCap,
487
+ },
488
+ samples: {
489
+ retentionSec: Number((this.sampleRetentionMs / 1000).toFixed(1)),
490
+ totalSampleCount: this.samples.length,
491
+ recent,
492
+ },
493
+ config: {
494
+ pollIntervalSec: Number((this.pollIntervalMs / 1000).toFixed(1)),
495
+ thresholdMB: toMB(this.thresholdBytes),
496
+ recoveryMB: toMB(this.recoveryBytes),
497
+ growthThresholdMB: toMB(this.growthThresholdBytes),
498
+ growthWindowSec: Number((this.growthWindowMs / 1000).toFixed(1)),
499
+ minReportIntervalSec: Number((this.minReportIntervalMs / 1000).toFixed(1)),
500
+ maxReportsPerDay: this.maxReportsPerDay,
501
+ },
502
+ };
503
+ }
504
+ log(level, message) {
505
+ if (!this.logger)
506
+ return;
507
+ this.logger(level, message);
508
+ }
509
+ }