@rulecatch/ai-pooler 0.4.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.
package/dist/flush.js ADDED
@@ -0,0 +1,1114 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, writeFileSync, appendFileSync, unlinkSync, readdirSync } from 'fs';
3
+ import { homedir } from 'os';
4
+ import { join } from 'path';
5
+ import { randomBytes, createCipheriv, createHash, pbkdf2Sync } from 'crypto';
6
+
7
+ /**
8
+ * Client-side encryption for PII (Zero-Knowledge Architecture)
9
+ *
10
+ * All PII (emails, usernames, file paths) is encrypted on the client
11
+ * before being sent to Rulecatch API. We never see plaintext PII.
12
+ *
13
+ * The user's privacy key is derived from their password or a separate
14
+ * key they set during setup. Without this key, the data is unreadable.
15
+ */
16
+ const ALGORITHM = 'aes-256-gcm';
17
+ const IV_LENGTH = 16;
18
+ /**
19
+ * Encrypt a PII field (email, username, file path, etc.)
20
+ * Returns ciphertext + IV + auth tag for storage
21
+ */
22
+ function encryptPII(plaintext, key) {
23
+ if (!plaintext) {
24
+ return { ciphertext: '', iv: '', tag: '' };
25
+ }
26
+ const iv = randomBytes(IV_LENGTH);
27
+ const cipher = createCipheriv(ALGORITHM, key, iv);
28
+ const encrypted = Buffer.concat([
29
+ cipher.update(plaintext, 'utf8'),
30
+ cipher.final(),
31
+ ]);
32
+ const tag = cipher.getAuthTag();
33
+ return {
34
+ ciphertext: encrypted.toString('base64'),
35
+ iv: iv.toString('base64'),
36
+ tag: tag.toString('base64'),
37
+ };
38
+ }
39
+ /**
40
+ * Create a one-way hash for indexing/grouping
41
+ * Cannot be reversed, but same input = same hash (for deduplication)
42
+ *
43
+ * We use a truncated hash (16 chars) to prevent rainbow table attacks
44
+ * while still allowing grouping by the same identifier.
45
+ */
46
+ function hashForIndex(plaintext, salt) {
47
+ if (!plaintext) {
48
+ return '';
49
+ }
50
+ return createHash('sha256')
51
+ .update(plaintext + salt)
52
+ .digest('hex')
53
+ .slice(0, 16);
54
+ }
55
+ /**
56
+ * Encrypt all PII fields in an event payload
57
+ * Non-PII fields are passed through unchanged
58
+ */
59
+ function encryptEventPII(event, key, salt) {
60
+ const piiFields = [
61
+ 'accountEmail',
62
+ 'gitEmail',
63
+ 'gitUsername',
64
+ 'filePath',
65
+ 'cwd',
66
+ 'projectId',
67
+ ];
68
+ const arrayPiiFields = ['filesModified'];
69
+ const result = { ...event };
70
+ // Encrypt single PII fields
71
+ for (const field of piiFields) {
72
+ const value = event[field];
73
+ if (typeof value === 'string' && value) {
74
+ const encrypted = encryptPII(value, key);
75
+ result[`${field}_encrypted`] = encrypted.ciphertext;
76
+ result[`${field}_iv`] = encrypted.iv;
77
+ result[`${field}_tag`] = encrypted.tag;
78
+ result[`${field}_hash`] = hashForIndex(value, salt);
79
+ delete result[field]; // Remove plaintext
80
+ }
81
+ }
82
+ // Encrypt array PII fields
83
+ for (const field of arrayPiiFields) {
84
+ const value = event[field];
85
+ if (Array.isArray(value)) {
86
+ const encryptedArray = value.map((item) => {
87
+ if (typeof item === 'string') {
88
+ const encrypted = encryptPII(item, key);
89
+ return {
90
+ encrypted: encrypted.ciphertext,
91
+ iv: encrypted.iv,
92
+ tag: encrypted.tag,
93
+ hash: hashForIndex(item, salt),
94
+ };
95
+ }
96
+ return item;
97
+ });
98
+ result[`${field}_encrypted`] = encryptedArray;
99
+ result[`${field}_hashes`] = value.map((item) => typeof item === 'string' ? hashForIndex(item, salt) : '');
100
+ delete result[field]; // Remove plaintext
101
+ }
102
+ }
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Backpressure & Flow Control for Rulecatch AI Pooler
108
+ *
109
+ * Implements smart throttling that:
110
+ * 1. Asks server "how much can I send?" before flushing
111
+ * 2. Respects rate limits (429) with exponential backoff
112
+ * 3. Gradually drains buffer when server recovers
113
+ * 4. Prevents thundering herd after outages
114
+ */
115
+ // Paths
116
+ const RULECATCH_DIR$1 = join(homedir(), '.claude', 'rulecatch');
117
+ const BACKPRESSURE_STATE_FILE = join(RULECATCH_DIR$1, '.backpressure-state');
118
+ const LOG_FILE$1 = join(RULECATCH_DIR$1, 'flush.log');
119
+ // API version
120
+ const API_VERSION$1 = '/api/v1';
121
+ /**
122
+ * Default capacity when server is unreachable
123
+ */
124
+ const DEFAULT_CAPACITY = {
125
+ ready: false,
126
+ maxBatchSize: 10,
127
+ delayBetweenBatches: 5000,
128
+ retryAfter: 30,
129
+ };
130
+ /**
131
+ * Backoff configuration
132
+ */
133
+ const BACKOFF_CONFIG = {
134
+ /** Base delay in ms */
135
+ baseDelay: 1000,
136
+ /** Maximum delay in ms (5 minutes) */
137
+ maxDelay: 300000,
138
+ /** Multiplier for each failure */
139
+ multiplier: 2,
140
+ /** Max consecutive failures before circuit breaker */
141
+ maxFailures: 10};
142
+ function log$1(message) {
143
+ const timestamp = new Date().toISOString();
144
+ try {
145
+ appendFileSync(LOG_FILE$1, `[${timestamp}] [backpressure] ${message}\n`);
146
+ }
147
+ catch {
148
+ // Ignore
149
+ }
150
+ }
151
+ function getBaseUrl$1(region, endpointOverride) {
152
+ // Allow override for local testing (e.g., RULECATCH_API_URL=http://localhost:3001)
153
+ if (process.env.RULECATCH_API_URL) {
154
+ return process.env.RULECATCH_API_URL;
155
+ }
156
+ if (endpointOverride) {
157
+ return endpointOverride;
158
+ }
159
+ return region === 'eu'
160
+ ? 'https://api-eu.rulecatch.ai'
161
+ : 'https://api.rulecatch.ai';
162
+ }
163
+ /**
164
+ * Load persisted backpressure state
165
+ */
166
+ function loadState() {
167
+ try {
168
+ if (existsSync(BACKPRESSURE_STATE_FILE)) {
169
+ const content = readFileSync(BACKPRESSURE_STATE_FILE, 'utf-8');
170
+ return JSON.parse(content);
171
+ }
172
+ }
173
+ catch {
174
+ // Corrupt state, start fresh
175
+ }
176
+ return {
177
+ backoffLevel: 0,
178
+ nextAttemptAfter: 0,
179
+ lastCapacity: null,
180
+ consecutiveFailures: 0,
181
+ lastSuccessTime: 0,
182
+ pendingEventCount: 0,
183
+ };
184
+ }
185
+ /**
186
+ * Save backpressure state
187
+ */
188
+ function saveState(state) {
189
+ try {
190
+ writeFileSync(BACKPRESSURE_STATE_FILE, JSON.stringify(state, null, 2));
191
+ }
192
+ catch {
193
+ log$1('Failed to save backpressure state');
194
+ }
195
+ }
196
+ /**
197
+ * Calculate backoff delay based on failure count
198
+ */
199
+ function calculateBackoffDelay(backoffLevel) {
200
+ const delay = BACKOFF_CONFIG.baseDelay * Math.pow(BACKOFF_CONFIG.multiplier, backoffLevel);
201
+ return Math.min(delay, BACKOFF_CONFIG.maxDelay);
202
+ }
203
+ /**
204
+ * Check if we're allowed to attempt a flush
205
+ */
206
+ function canAttemptFlush(state) {
207
+ const now = Date.now();
208
+ // Check circuit breaker
209
+ if (state.consecutiveFailures >= BACKOFF_CONFIG.maxFailures) {
210
+ const waitMs = state.nextAttemptAfter - now;
211
+ if (waitMs > 0) {
212
+ return {
213
+ allowed: false,
214
+ waitMs,
215
+ reason: `Circuit breaker open: ${state.consecutiveFailures} consecutive failures. Retry in ${Math.ceil(waitMs / 1000)}s`,
216
+ };
217
+ }
218
+ }
219
+ // Check backoff timer
220
+ if (state.nextAttemptAfter > now) {
221
+ const waitMs = state.nextAttemptAfter - now;
222
+ return {
223
+ allowed: false,
224
+ waitMs,
225
+ reason: `Backing off: retry in ${Math.ceil(waitMs / 1000)}s (level ${state.backoffLevel})`,
226
+ };
227
+ }
228
+ return { allowed: true, waitMs: 0, reason: 'OK' };
229
+ }
230
+ /**
231
+ * Ask server how much we can send
232
+ */
233
+ async function getServerCapacity(apiKey, region, sessionToken, pendingCount, endpointOverride) {
234
+ const baseUrl = getBaseUrl$1(region, endpointOverride);
235
+ const endpoint = `${baseUrl}${API_VERSION$1}/ai/pooler/capacity`;
236
+ try {
237
+ const headers = {
238
+ 'Content-Type': 'application/json',
239
+ 'Authorization': `Bearer ${apiKey}`,
240
+ };
241
+ if (sessionToken) {
242
+ headers['X-Pooler-Token'] = sessionToken;
243
+ }
244
+ const response = await fetch(endpoint, {
245
+ method: 'POST',
246
+ headers,
247
+ body: JSON.stringify({
248
+ pendingEventCount: pendingCount,
249
+ clientVersion: '0.4.0',
250
+ }),
251
+ signal: AbortSignal.timeout(10000), // 10s timeout
252
+ });
253
+ if (response.status === 429) {
254
+ // Rate limited - parse Retry-After header
255
+ const retryAfter = parseInt(response.headers.get('Retry-After') || '60', 10);
256
+ log$1(`Rate limited by server, retry after ${retryAfter}s`);
257
+ return {
258
+ ready: false,
259
+ maxBatchSize: 0,
260
+ delayBetweenBatches: 0,
261
+ retryAfter,
262
+ message: 'Rate limited',
263
+ };
264
+ }
265
+ if (response.status === 503) {
266
+ // Server overloaded
267
+ const retryAfter = parseInt(response.headers.get('Retry-After') || '120', 10);
268
+ log$1(`Server overloaded, retry after ${retryAfter}s`);
269
+ return {
270
+ ready: false,
271
+ maxBatchSize: 0,
272
+ delayBetweenBatches: 0,
273
+ retryAfter,
274
+ message: 'Server overloaded',
275
+ };
276
+ }
277
+ if (!response.ok) {
278
+ log$1(`Capacity check failed: ${response.status}`);
279
+ return DEFAULT_CAPACITY;
280
+ }
281
+ const data = await response.json();
282
+ log$1(`Server capacity: ready=${data.ready}, maxBatch=${data.maxBatchSize}, delay=${data.delayBetweenBatches}ms, load=${data.loadPercent}%`);
283
+ return data;
284
+ }
285
+ catch (err) {
286
+ log$1(`Failed to get server capacity: ${err}`);
287
+ return DEFAULT_CAPACITY;
288
+ }
289
+ }
290
+ /**
291
+ * Record a successful flush
292
+ */
293
+ function recordSuccess(state, eventsSent) {
294
+ const now = Date.now();
295
+ // Reset backoff on success
296
+ const newState = {
297
+ ...state,
298
+ backoffLevel: Math.max(0, state.backoffLevel - 1), // Gradually reduce backoff
299
+ consecutiveFailures: 0,
300
+ lastSuccessTime: now,
301
+ nextAttemptAfter: 0, // Can attempt immediately
302
+ pendingEventCount: Math.max(0, state.pendingEventCount - eventsSent),
303
+ };
304
+ log$1(`Success: sent ${eventsSent} events, backoff level now ${newState.backoffLevel}`);
305
+ return newState;
306
+ }
307
+ /**
308
+ * Record a failed flush attempt
309
+ */
310
+ function recordFailure(state, statusCode, retryAfterHeader) {
311
+ const now = Date.now();
312
+ const newFailureCount = state.consecutiveFailures + 1;
313
+ const newBackoffLevel = Math.min(state.backoffLevel + 1, 10); // Cap at level 10
314
+ let nextAttemptDelay;
315
+ // If server provided Retry-After, use that
316
+ if (retryAfterHeader) {
317
+ nextAttemptDelay = parseInt(retryAfterHeader, 10) * 1000;
318
+ }
319
+ else if (statusCode === 429 || statusCode === 503) {
320
+ // Rate limited or overloaded - use longer delay
321
+ nextAttemptDelay = calculateBackoffDelay(newBackoffLevel) * 2;
322
+ }
323
+ else {
324
+ // Other error - standard backoff
325
+ nextAttemptDelay = calculateBackoffDelay(newBackoffLevel);
326
+ }
327
+ const newState = {
328
+ ...state,
329
+ backoffLevel: newBackoffLevel,
330
+ consecutiveFailures: newFailureCount,
331
+ nextAttemptAfter: now + nextAttemptDelay,
332
+ };
333
+ log$1(`Failure (${statusCode}): count=${newFailureCount}, backoff level=${newBackoffLevel}, retry in ${Math.ceil(nextAttemptDelay / 1000)}s`);
334
+ return newState;
335
+ }
336
+ /**
337
+ * Update pending event count
338
+ */
339
+ function updatePendingCount(state, count) {
340
+ return { ...state, pendingEventCount: count };
341
+ }
342
+ /**
343
+ * Get human-readable status for CLI
344
+ */
345
+ function getStatusSummary(state) {
346
+ const lines = [];
347
+ if (state.consecutiveFailures > 0) {
348
+ lines.push(`Consecutive failures: ${state.consecutiveFailures}`);
349
+ }
350
+ if (state.backoffLevel > 0) {
351
+ lines.push(`Backoff level: ${state.backoffLevel}/10`);
352
+ }
353
+ if (state.nextAttemptAfter > Date.now()) {
354
+ const waitSec = Math.ceil((state.nextAttemptAfter - Date.now()) / 1000);
355
+ lines.push(`Next attempt in: ${waitSec}s`);
356
+ }
357
+ if (state.pendingEventCount > 0) {
358
+ lines.push(`Pending events: ${state.pendingEventCount}`);
359
+ }
360
+ if (state.lastSuccessTime > 0) {
361
+ const ago = Math.floor((Date.now() - state.lastSuccessTime) / 1000);
362
+ lines.push(`Last success: ${ago}s ago`);
363
+ }
364
+ if (state.lastCapacity) {
365
+ lines.push(`Server load: ${state.lastCapacity.loadPercent || 'unknown'}%`);
366
+ lines.push(`Max batch: ${state.lastCapacity.maxBatchSize}`);
367
+ }
368
+ return lines.length > 0 ? lines.join('\n') : 'Healthy (no backpressure)';
369
+ }
370
+
371
+ /**
372
+ * Rulecatch Flush Script with Backpressure Control
373
+ *
374
+ * Reads buffered event files from ~/.claude/rulecatch/buffer/,
375
+ * encrypts PII, acquires/reuses session token, POSTs to API
376
+ * with intelligent flow control to prevent overwhelming the server.
377
+ *
378
+ * Features:
379
+ * - Asks server "how much can I send?" before flushing
380
+ * - Respects rate limits (429) with exponential backoff
381
+ * - Gradually drains buffer when server recovers
382
+ * - Prevents thundering herd after outages
383
+ *
384
+ * Usage:
385
+ * node flush.js # Flush if batch threshold met
386
+ * node flush.js --force # Flush all regardless of threshold
387
+ * node flush.js --status # Show backpressure status
388
+ */
389
+ // Paths
390
+ const RULECATCH_DIR = join(homedir(), '.claude', 'rulecatch');
391
+ const CONFIG_PATH = join(RULECATCH_DIR, 'config.json');
392
+ const BUFFER_DIR = join(RULECATCH_DIR, 'buffer');
393
+ const SESSION_FILE = join(RULECATCH_DIR, '.session');
394
+ const LOCK_FILE = join(RULECATCH_DIR, '.flush-lock');
395
+ const LOG_FILE = join(RULECATCH_DIR, 'flush.log');
396
+ const PAUSED_FILE = join(RULECATCH_DIR, '.paused');
397
+ // API version
398
+ const API_VERSION = '/api/v1';
399
+ function getDebugLevel(config) {
400
+ return config?.debugLevel || 'verbose';
401
+ }
402
+ const DEFAULT_DEBUG_LOG_FILE = '/var/log/ai-pooler.log';
403
+ /**
404
+ * Sanitize text to remove any potential PII.
405
+ * All sensitive data is masked with exactly 6 Xs: XXXXXX
406
+ *
407
+ * Catches:
408
+ * - Home directory paths (e.g., /home/username/, /Users/username/)
409
+ * - Windows user paths (C:\Users\username\)
410
+ * - Email addresses
411
+ * - API keys, tokens, passwords
412
+ * - Common PII field patterns
413
+ */
414
+ function sanitizeForErrorReport(text) {
415
+ return text
416
+ // Unix home paths: /home/username/ or /Users/username/
417
+ .replace(/\/(?:home|Users)\/[^\/\s]+/g, '/home/XXXXXX')
418
+ // Windows paths: C:\Users\username\
419
+ .replace(/[A-Z]:\\Users\\[^\\s]+/gi, 'C:\\Users\\XXXXXX')
420
+ // Email addresses
421
+ .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, 'XXXXXX@XXXXXX')
422
+ // API keys (dc_...)
423
+ .replace(/dc_[a-zA-Z0-9_-]+/g, 'dc_XXXXXX')
424
+ // Pooler tokens
425
+ .replace(/pooler_[a-zA-Z0-9_]+/g, 'pooler_XXXXXX')
426
+ // Bearer tokens
427
+ .replace(/Bearer\s+[a-zA-Z0-9+/=_-]{20,}/g, 'Bearer XXXXXX')
428
+ // Password patterns (password=..., password:..., "password":"...")
429
+ .replace(/password["']?\s*[:=]\s*["']?[^"'\s,}]+/gi, 'password=XXXXXX')
430
+ // Secret patterns
431
+ .replace(/secret["']?\s*[:=]\s*["']?[^"'\s,}]+/gi, 'secret=XXXXXX')
432
+ // Key patterns (apiKey, api_key, etc.)
433
+ .replace(/(?:api[_-]?key|apikey)["']?\s*[:=]\s*["']?[^"'\s,}]+/gi, 'apiKey=XXXXXX')
434
+ // MongoDB connection strings
435
+ .replace(/mongodb(\+srv)?:\/\/[^@]+@/gi, 'mongodb$1://XXXXXX:XXXXXX@')
436
+ // Generic URLs with credentials
437
+ .replace(/:\/\/[^:]+:[^@]+@/g, '://XXXXXX:XXXXXX@');
438
+ }
439
+ /**
440
+ * Report an error to the Rulecatch error tracking endpoint.
441
+ * Fires and forgets - we don't want error reporting to block exit.
442
+ *
443
+ * IMPORTANT: All PII is stripped before sending:
444
+ * - File paths with usernames are redacted
445
+ * - Email addresses are redacted
446
+ * - API keys and tokens are redacted
447
+ */
448
+ async function reportError(error, phase, config) {
449
+ try {
450
+ const rawMessage = error instanceof Error ? error.message : String(error);
451
+ const rawStack = error instanceof Error ? error.stack : undefined;
452
+ // Sanitize to remove any PII
453
+ const errorMessage = sanitizeForErrorReport(rawMessage);
454
+ const stack = rawStack ? sanitizeForErrorReport(rawStack) : undefined;
455
+ const region = config?.region || 'us';
456
+ const baseUrl = region === 'eu'
457
+ ? 'https://api-eu.rulecatch.ai'
458
+ : 'https://api.rulecatch.ai';
459
+ const report = {
460
+ error: errorMessage,
461
+ stack,
462
+ phase,
463
+ region,
464
+ // Note: projectId could be PII if user named it after themselves
465
+ // We hash it for grouping but don't send raw
466
+ projectIdHash: config?.projectId
467
+ ? require('crypto').createHash('sha256').update(config.projectId).digest('hex').slice(0, 16)
468
+ : undefined,
469
+ nodeVersion: process.version,
470
+ platform: process.platform,
471
+ arch: process.arch,
472
+ timestamp: new Date().toISOString(),
473
+ // Config info (only boolean flags, no values)
474
+ hasApiKey: !!config?.apiKey,
475
+ hasEncryptionKey: !!config?.encryptionKey,
476
+ hasSalt: !!config?.salt,
477
+ hasBatchSize: !!config?.batchSize,
478
+ hasDebugEnabled: !!config?.debug,
479
+ };
480
+ // Fire and forget - don't await, use short timeout
481
+ fetch(`${baseUrl}/api/v1/pooler/crash-reports`, {
482
+ method: 'POST',
483
+ headers: { 'Content-Type': 'application/json' },
484
+ body: JSON.stringify(report),
485
+ signal: AbortSignal.timeout(5000), // 5s max
486
+ }).catch(() => {
487
+ // Silently ignore - we tried our best
488
+ });
489
+ // Give it a moment to send before process exits
490
+ await new Promise(resolve => setTimeout(resolve, 100));
491
+ }
492
+ catch {
493
+ // Error reporting itself failed - nothing we can do
494
+ }
495
+ }
496
+ /**
497
+ * Expand ~ to home directory in paths
498
+ */
499
+ function expandPath(path) {
500
+ if (path.startsWith('~/')) {
501
+ return join(homedir(), path.slice(2));
502
+ }
503
+ return path;
504
+ }
505
+ /**
506
+ * Get the debug log file path from config or default
507
+ */
508
+ function getDebugLogPath(config) {
509
+ const configPath = config?.debugLogFile;
510
+ if (configPath) {
511
+ return expandPath(configPath);
512
+ }
513
+ return DEFAULT_DEBUG_LOG_FILE;
514
+ }
515
+ // ANSI color codes for terminal output
516
+ const colors = {
517
+ reset: '\x1b[0m',
518
+ bold: '\x1b[1m',
519
+ cyan: '\x1b[36m',
520
+ green: '\x1b[32m',
521
+ yellow: '\x1b[33m',
522
+ magenta: '\x1b[35m',
523
+ purple: '\x1b[95m', // Bright magenta/purple
524
+ dim: '\x1b[2m',
525
+ };
526
+ /**
527
+ * Debug log - only outputs when debug mode is enabled
528
+ * Writes to debugLogFile (default: /var/log/ai-pooler.log)
529
+ *
530
+ * @param config - Config object
531
+ * @param message - Log message
532
+ * @param details - Optional key-value details
533
+ * @param level - Minimum level required to log this message (default: 'verbose')
534
+ */
535
+ function debugLog(config, message, details, level = 'verbose') {
536
+ if (!config?.debug)
537
+ return;
538
+ const configLevel = getDebugLevel(config);
539
+ const levelPriority = { minimal: 1, verbose: 2, full: 3 };
540
+ // Only log if config level is >= required level
541
+ if (levelPriority[configLevel] < levelPriority[level])
542
+ return;
543
+ const logPath = getDebugLogPath(config);
544
+ const timestamp = new Date().toISOString();
545
+ // Strip ANSI color codes for file output
546
+ const cleanMessage = message.replace(/\x1b\[[0-9;]*m/g, '');
547
+ let logLine = `[${timestamp}] ${cleanMessage}`;
548
+ if (details) {
549
+ const detailsStr = Object.entries(details)
550
+ .map(([key, value]) => {
551
+ // Strip ANSI codes from values too
552
+ const cleanValue = String(value).replace(/\x1b\[[0-9;]*m/g, '');
553
+ return `${key}=${cleanValue}`;
554
+ })
555
+ .join(' | ');
556
+ logLine += ` | ${detailsStr}`;
557
+ }
558
+ try {
559
+ appendFileSync(logPath, logLine + '\n');
560
+ }
561
+ catch {
562
+ // If we can't write to the log file, silently ignore
563
+ // (e.g., /var/log not writable without sudo)
564
+ }
565
+ }
566
+ /**
567
+ * Log full JSON payload (only at 'full' debug level)
568
+ */
569
+ function debugLogJson(config, label, data) {
570
+ if (!config?.debug)
571
+ return;
572
+ if (getDebugLevel(config) !== 'full')
573
+ return;
574
+ const logPath = getDebugLogPath(config);
575
+ const timestamp = new Date().toISOString();
576
+ try {
577
+ const jsonStr = JSON.stringify(data, null, 2);
578
+ appendFileSync(logPath, `[${timestamp}] === ${label} ===\n${jsonStr}\n\n`);
579
+ }
580
+ catch {
581
+ // Silently ignore
582
+ }
583
+ }
584
+ /**
585
+ * Check if data collection is paused due to subscription issues.
586
+ */
587
+ function isPaused() {
588
+ try {
589
+ if (existsSync(PAUSED_FILE)) {
590
+ return JSON.parse(readFileSync(PAUSED_FILE, 'utf-8'));
591
+ }
592
+ }
593
+ catch {
594
+ // Corrupt file - ignore
595
+ }
596
+ return null;
597
+ }
598
+ /**
599
+ * Write paused marker file when subscription is invalid.
600
+ */
601
+ function writePausedFile(info) {
602
+ const pausedInfo = {
603
+ ...info,
604
+ pausedAt: new Date().toISOString(),
605
+ };
606
+ writeFileSync(PAUSED_FILE, JSON.stringify(pausedInfo, null, 2), { mode: 0o600 });
607
+ }
608
+ function log(message) {
609
+ const timestamp = new Date().toISOString();
610
+ try {
611
+ appendFileSync(LOG_FILE, `[${timestamp}] ${message}\n`);
612
+ }
613
+ catch {
614
+ // Ignore
615
+ }
616
+ }
617
+ function getBaseUrl(region, endpointOverride) {
618
+ // Allow override for local testing (e.g., RULECATCH_API_URL=http://localhost:3001)
619
+ if (process.env.RULECATCH_API_URL) {
620
+ return process.env.RULECATCH_API_URL;
621
+ }
622
+ // Allow override from config.endpoint
623
+ if (endpointOverride) {
624
+ return endpointOverride;
625
+ }
626
+ return region === 'eu'
627
+ ? 'https://api-eu.rulecatch.ai'
628
+ : 'https://api.rulecatch.ai';
629
+ }
630
+ function deriveEncryptionKey(password, salt) {
631
+ return pbkdf2Sync(password, salt, 100000, 32, 'sha256');
632
+ }
633
+ /**
634
+ * Acquire a file lock to prevent concurrent flushes.
635
+ */
636
+ function acquireLock() {
637
+ try {
638
+ if (existsSync(LOCK_FILE)) {
639
+ const lockContent = readFileSync(LOCK_FILE, 'utf-8').trim();
640
+ const lockTime = parseInt(lockContent, 10);
641
+ // Stale lock (older than 60 seconds)
642
+ if (Date.now() - lockTime > 60000) {
643
+ unlinkSync(LOCK_FILE);
644
+ }
645
+ else {
646
+ return false;
647
+ }
648
+ }
649
+ writeFileSync(LOCK_FILE, String(Date.now()));
650
+ return true;
651
+ }
652
+ catch {
653
+ return false;
654
+ }
655
+ }
656
+ function releaseLock() {
657
+ try {
658
+ if (existsSync(LOCK_FILE)) {
659
+ unlinkSync(LOCK_FILE);
660
+ }
661
+ }
662
+ catch {
663
+ // Ignore
664
+ }
665
+ }
666
+ /**
667
+ * Load or acquire session token.
668
+ */
669
+ async function getSessionToken(config) {
670
+ // Check cached token
671
+ if (existsSync(SESSION_FILE)) {
672
+ try {
673
+ const cached = JSON.parse(readFileSync(SESSION_FILE, 'utf-8'));
674
+ if (cached.token && cached.expiry > Date.now()) {
675
+ return cached.token;
676
+ }
677
+ }
678
+ catch {
679
+ // Expired or corrupt, re-acquire
680
+ }
681
+ }
682
+ // Acquire new token
683
+ const baseUrl = getBaseUrl(config.region, config.endpoint);
684
+ const tokenEndpoint = `${baseUrl}${API_VERSION}/ai/pooler/session`;
685
+ const regionLabel = config.region === 'eu' ? 'api-eu' : 'api';
686
+ const startTime = Date.now();
687
+ try {
688
+ const response = await fetch(tokenEndpoint, {
689
+ method: 'POST',
690
+ headers: {
691
+ 'Content-Type': 'application/json',
692
+ 'Authorization': `Bearer ${config.apiKey}`,
693
+ },
694
+ body: JSON.stringify({
695
+ projectId: config.projectId,
696
+ region: config.region,
697
+ encrypted: true,
698
+ }),
699
+ });
700
+ const durationMs = Date.now() - startTime;
701
+ if (!response.ok) {
702
+ // Check if this is a subscription pause response (403 with status: 'paused')
703
+ if (response.status === 403) {
704
+ try {
705
+ const errorBody = await response.json();
706
+ if (errorBody.status === 'paused') {
707
+ // Write paused marker file
708
+ writePausedFile({
709
+ reason: errorBody.reason || 'subscription_expired',
710
+ message: errorBody.message || 'Subscription expired.',
711
+ region: errorBody.region || config.region,
712
+ dashboardUrl: errorBody.dashboardUrl || `https://dashboard${config.region === 'eu' ? '-eu' : ''}.rulecatch.ai`,
713
+ billingUrl: errorBody.billingUrl || `https://dashboard${config.region === 'eu' ? '-eu' : ''}.rulecatch.ai/billing`,
714
+ });
715
+ log(`Subscription paused: ${errorBody.reason}`);
716
+ debugLog(config, `${colors.yellow}⚠ SUBSCRIPTION PAUSED${colors.reset}`, {
717
+ reason: errorBody.reason,
718
+ billingUrl: errorBody.billingUrl,
719
+ });
720
+ // Return special marker
721
+ return 'PAUSED';
722
+ }
723
+ }
724
+ catch {
725
+ // Couldn't parse as pause response - fall through to generic error
726
+ }
727
+ }
728
+ log(`Failed to acquire session token: ${response.status}`);
729
+ debugLog(config, `${colors.yellow}✗ Session token failed${colors.reset}`, {
730
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
731
+ endpoint: regionLabel,
732
+ status: response.status,
733
+ duration: `${durationMs}ms`,
734
+ });
735
+ return null;
736
+ }
737
+ const result = await response.json();
738
+ const cache = {
739
+ token: result.token,
740
+ // Refresh 1 hour before expiry
741
+ expiry: Date.now() + (result.expiresIn - 3600) * 1000,
742
+ };
743
+ writeFileSync(SESSION_FILE, JSON.stringify(cache), { mode: 0o600 });
744
+ log(`Session token acquired (expires in ${result.expiresIn}s)`);
745
+ debugLog(config, `${colors.green}✓ Session token acquired${colors.reset}`, {
746
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
747
+ endpoint: `${colors.cyan}${regionLabel}.rulecatch.ai${colors.reset}`,
748
+ duration: `${colors.green}${durationMs}ms${colors.reset}`,
749
+ expiresIn: `${Math.round(result.expiresIn / 3600)}h`,
750
+ });
751
+ return result.token;
752
+ }
753
+ catch (err) {
754
+ const durationMs = Date.now() - startTime;
755
+ log(`Error acquiring session token: ${err}`);
756
+ debugLog(config, `${colors.yellow}✗ Session token error${colors.reset}`, {
757
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
758
+ endpoint: regionLabel,
759
+ error: String(err),
760
+ duration: `${durationMs}ms`,
761
+ });
762
+ return null;
763
+ }
764
+ }
765
+ /**
766
+ * Read all buffer files and return events with file paths
767
+ */
768
+ function readBufferFiles() {
769
+ if (!existsSync(BUFFER_DIR)) {
770
+ return { events: [], filePaths: [] };
771
+ }
772
+ let files;
773
+ try {
774
+ files = readdirSync(BUFFER_DIR)
775
+ .filter(f => f.endsWith('.json'))
776
+ .sort(); // Process in chronological order
777
+ }
778
+ catch {
779
+ return { events: [], filePaths: [] };
780
+ }
781
+ const events = [];
782
+ const filePaths = [];
783
+ for (const file of files) {
784
+ const filePath = join(BUFFER_DIR, file);
785
+ try {
786
+ const content = readFileSync(filePath, 'utf-8');
787
+ const event = JSON.parse(content);
788
+ events.push(event);
789
+ filePaths.push(filePath);
790
+ }
791
+ catch (err) {
792
+ log(`Failed to read buffer file ${file}: ${err}`);
793
+ // Delete corrupt files
794
+ try {
795
+ unlinkSync(filePath);
796
+ }
797
+ catch { /* ignore */ }
798
+ }
799
+ }
800
+ return { events, filePaths };
801
+ }
802
+ /**
803
+ * Delete successfully sent files
804
+ */
805
+ function deleteFiles(filePaths) {
806
+ for (const filePath of filePaths) {
807
+ try {
808
+ unlinkSync(filePath);
809
+ }
810
+ catch {
811
+ // Ignore
812
+ }
813
+ }
814
+ }
815
+ /**
816
+ * Send a batch of events to the API
817
+ */
818
+ async function sendBatch(events, config, sessionToken) {
819
+ const baseUrl = getBaseUrl(config.region, config.endpoint);
820
+ const endpoint = `${baseUrl}${API_VERSION}/ai/ingest`;
821
+ const regionLabel = config.region === 'eu' ? 'api-eu' : 'api';
822
+ // FULL: Log raw events received from buffer
823
+ debugLogJson(config, 'RAW EVENTS FROM BUFFER (before encryption)', events);
824
+ // Actual headers for the request
825
+ const headers = {
826
+ 'Content-Type': 'application/json',
827
+ 'Authorization': `Bearer ${config.apiKey}`,
828
+ };
829
+ if (sessionToken) {
830
+ headers['X-Pooler-Token'] = sessionToken;
831
+ }
832
+ // FULL: Log endpoint and headers (masked for security)
833
+ debugLog(config, 'REQUEST DETAILS', {
834
+ endpoint,
835
+ method: 'POST',
836
+ headers: JSON.stringify({
837
+ 'Content-Type': 'application/json',
838
+ 'Authorization': `Bearer ${config.apiKey.slice(0, 10)}...`,
839
+ 'X-Pooler-Token': sessionToken ? `${sessionToken.slice(0, 20)}...` : 'none',
840
+ }),
841
+ }, 'full');
842
+ // Encrypt PII fields
843
+ const key = deriveEncryptionKey(config.encryptionKey, config.salt);
844
+ const processedEvents = events.map(event => encryptEventPII(event, key, config.salt));
845
+ // FULL: Log processed events after encryption
846
+ debugLogJson(config, 'PROCESSED EVENTS (after encryption)', processedEvents);
847
+ const requestBody = {
848
+ projectId: config.projectId, // REQUIRED: must be at top level
849
+ events: processedEvents,
850
+ encryptionSalt: config.salt,
851
+ region: config.region,
852
+ endpoint: endpoint,
853
+ sentAt: new Date().toISOString(),
854
+ ...(config.autoGeneratedKey && { autoGeneratedKey: true }),
855
+ };
856
+ // FULL: Log full request body
857
+ debugLogJson(config, 'FULL REQUEST BODY', requestBody);
858
+ const body = JSON.stringify(requestBody);
859
+ const startTime = Date.now();
860
+ try {
861
+ const response = await fetch(endpoint, {
862
+ method: 'POST',
863
+ headers,
864
+ body,
865
+ signal: AbortSignal.timeout(30000), // 30s timeout
866
+ });
867
+ const durationMs = Date.now() - startTime;
868
+ const retryAfter = response.headers.get('Retry-After') || undefined;
869
+ if (!response.ok) {
870
+ const text = await response.text().catch(() => '');
871
+ log(`API error ${response.status}: ${text}`);
872
+ // Check if this is a subscription pause response (403 with status: 'paused')
873
+ if (response.status === 403) {
874
+ try {
875
+ const errorBody = JSON.parse(text);
876
+ if (errorBody.status === 'paused') {
877
+ // Write paused marker file
878
+ writePausedFile({
879
+ reason: errorBody.reason || 'subscription_expired',
880
+ message: errorBody.message || 'Subscription expired.',
881
+ region: errorBody.region || config.region,
882
+ dashboardUrl: errorBody.dashboardUrl || `https://dashboard${config.region === 'eu' ? '-eu' : ''}.rulecatch.ai`,
883
+ billingUrl: errorBody.billingUrl || `https://dashboard${config.region === 'eu' ? '-eu' : ''}.rulecatch.ai/billing`,
884
+ });
885
+ log(`Subscription paused by ingest endpoint: ${errorBody.reason}`);
886
+ debugLog(config, `${colors.yellow}⚠ SUBSCRIPTION PAUSED (ingest rejected)${colors.reset}`, {
887
+ reason: errorBody.reason,
888
+ billingUrl: errorBody.billingUrl,
889
+ });
890
+ // Return special marker to indicate paused state
891
+ return { ok: false, status: response.status, retryAfter: undefined, durationMs, paused: true };
892
+ }
893
+ }
894
+ catch {
895
+ // Couldn't parse as pause response - continue with generic error handling
896
+ }
897
+ }
898
+ // Debug log for failed request
899
+ debugLog(config, `${colors.yellow}✗ POST failed${colors.reset}`, {
900
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
901
+ endpoint: regionLabel,
902
+ status: response.status,
903
+ duration: `${durationMs}ms`,
904
+ });
905
+ }
906
+ else {
907
+ // Build summary of what was sent
908
+ const toolCounts = {};
909
+ for (const event of events) {
910
+ const toolName = event.toolName;
911
+ const eventType = event.type;
912
+ const label = toolName || eventType || 'unknown';
913
+ toolCounts[label] = (toolCounts[label] || 0) + 1;
914
+ }
915
+ const toolsSummary = Object.entries(toolCounts)
916
+ .map(([name, count]) => count > 1 ? `${name}(${count})` : name)
917
+ .join(', ');
918
+ // Debug log for successful request
919
+ debugLog(config, `${colors.green}✓ ${events.length} events sent${colors.reset}`, {
920
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
921
+ endpoint: `${colors.cyan}${regionLabel}.rulecatch.ai${colors.reset}`,
922
+ events: `${colors.cyan}${toolsSummary}${colors.reset}`,
923
+ duration: `${colors.green}${durationMs}ms${colors.reset}`,
924
+ });
925
+ }
926
+ return { ok: response.ok, status: response.status, retryAfter, durationMs };
927
+ }
928
+ catch (err) {
929
+ const durationMs = Date.now() - startTime;
930
+ log(`Network error: ${err}`);
931
+ // Debug log for network error
932
+ debugLog(config, `${colors.yellow}✗ Network error${colors.reset}`, {
933
+ region: `${colors.cyan}${config.region.toUpperCase()}${colors.reset}`,
934
+ endpoint: regionLabel,
935
+ error: String(err),
936
+ duration: `${durationMs}ms`,
937
+ });
938
+ return { ok: false, status: 0, retryAfter: '30', durationMs };
939
+ }
940
+ }
941
+ async function main() {
942
+ const args = process.argv.slice(2);
943
+ const force = args.includes('--force');
944
+ const showStatus = args.includes('--status');
945
+ // Show status and exit
946
+ if (showStatus) {
947
+ const state = loadState();
948
+ console.log('\nBackpressure Status:');
949
+ console.log('-------------------');
950
+ console.log(getStatusSummary(state));
951
+ console.log('');
952
+ process.exit(0);
953
+ }
954
+ // No config = not set up
955
+ if (!existsSync(CONFIG_PATH)) {
956
+ process.exit(0);
957
+ }
958
+ // Check if paused due to subscription issues
959
+ const pausedInfo = isPaused();
960
+ if (pausedInfo) {
961
+ // Silently exit - data collection is paused
962
+ // User must run `npx @rulecatch/ai-pooler reactivate` to resume
963
+ log('Data collection paused - subscription issue. Run: npx @rulecatch/ai-pooler reactivate');
964
+ process.exit(0);
965
+ }
966
+ let config;
967
+ try {
968
+ config = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
969
+ globalConfig = config; // Store for error reporting
970
+ }
971
+ catch (err) {
972
+ log('Failed to parse config.json');
973
+ await reportError(err, 'config', null);
974
+ process.exit(1);
975
+ }
976
+ if (!config.apiKey) {
977
+ process.exit(0);
978
+ }
979
+ // Log startup if debug enabled
980
+ debugLog(config, 'Flush script started', {
981
+ debug: config.debug,
982
+ debugLevel: config.debugLevel || 'verbose',
983
+ debugLogFile: config.debugLogFile || DEFAULT_DEBUG_LOG_FILE,
984
+ region: config.region,
985
+ }, 'minimal');
986
+ // Read buffer files
987
+ const { events, filePaths } = readBufferFiles();
988
+ // FULL: Log buffer file paths
989
+ if (filePaths.length > 0) {
990
+ debugLog(config, `Read ${filePaths.length} buffer files`, {
991
+ files: filePaths.map(f => f.split('/').pop()).join(', '),
992
+ }, 'full');
993
+ }
994
+ if (events.length === 0) {
995
+ process.exit(0);
996
+ }
997
+ const defaultBatchSize = config.batchSize || 20;
998
+ // If not forcing and below threshold, exit
999
+ if (!force && events.length < defaultBatchSize) {
1000
+ process.exit(0);
1001
+ }
1002
+ // Load backpressure state
1003
+ let state = loadState();
1004
+ state = updatePendingCount(state, events.length);
1005
+ // Check if we're allowed to flush
1006
+ const canFlush = canAttemptFlush(state);
1007
+ if (!canFlush.allowed) {
1008
+ log(canFlush.reason);
1009
+ console.log(canFlush.reason);
1010
+ saveState(state);
1011
+ process.exit(0);
1012
+ }
1013
+ // Acquire lock
1014
+ if (!acquireLock()) {
1015
+ log('Another flush is running, exiting');
1016
+ process.exit(0);
1017
+ }
1018
+ try {
1019
+ // Get session token
1020
+ const sessionToken = await getSessionToken(config);
1021
+ // Check if subscription is paused
1022
+ if (sessionToken === 'PAUSED') {
1023
+ // Paused file was written by getSessionToken
1024
+ // Exit gracefully - user needs to reactivate
1025
+ log('Subscription paused. Run: npx @rulecatch/ai-pooler reactivate');
1026
+ releaseLock();
1027
+ process.exit(0);
1028
+ }
1029
+ // Ask server how much we can send
1030
+ const capacity = await getServerCapacity(config.apiKey, config.region, sessionToken, events.length, config.endpoint);
1031
+ state.lastCapacity = capacity;
1032
+ if (!capacity.ready) {
1033
+ // Server not ready - back off
1034
+ state.nextAttemptAfter = Date.now() + (capacity.retryAfter * 1000);
1035
+ log(`Server not ready: ${capacity.message || 'backing off for ' + capacity.retryAfter + 's'}`);
1036
+ console.log(`Server not ready, will retry in ${capacity.retryAfter}s`);
1037
+ saveState(state);
1038
+ releaseLock();
1039
+ process.exit(0);
1040
+ }
1041
+ // Send events in batches with controlled pace
1042
+ let totalSent = 0;
1043
+ let currentIndex = 0;
1044
+ while (currentIndex < events.length) {
1045
+ // Take a batch (server-controlled size)
1046
+ const batchSize = Math.min(capacity.maxBatchSize, events.length - currentIndex);
1047
+ const batchEvents = events.slice(currentIndex, currentIndex + batchSize);
1048
+ const batchFilePaths = filePaths.slice(currentIndex, currentIndex + batchSize);
1049
+ log(`Sending batch ${Math.floor(currentIndex / capacity.maxBatchSize) + 1}: ${batchSize} events`);
1050
+ const result = await sendBatch(batchEvents, config, sessionToken);
1051
+ if (result.ok) {
1052
+ // Success - delete sent files
1053
+ deleteFiles(batchFilePaths);
1054
+ totalSent += batchSize;
1055
+ currentIndex += batchSize;
1056
+ state = recordSuccess(state, batchSize);
1057
+ // If more events, wait before next batch (server-controlled delay)
1058
+ if (currentIndex < events.length && capacity.delayBetweenBatches > 0) {
1059
+ log(`Waiting ${capacity.delayBetweenBatches}ms before next batch`);
1060
+ await new Promise(resolve => setTimeout(resolve, capacity.delayBetweenBatches));
1061
+ // Re-check capacity every 100 events during large drains
1062
+ if (totalSent > 0 && totalSent % 100 === 0) {
1063
+ const newCapacity = await getServerCapacity(config.apiKey, config.region, sessionToken, events.length - currentIndex, config.endpoint);
1064
+ state.lastCapacity = newCapacity;
1065
+ if (!newCapacity.ready) {
1066
+ log(`Server requested pause during drain, ${events.length - currentIndex} events remaining`);
1067
+ break;
1068
+ }
1069
+ }
1070
+ }
1071
+ }
1072
+ else {
1073
+ // Check if subscription was paused by the server
1074
+ if (result.paused) {
1075
+ // Paused file was written by sendBatch
1076
+ log('Subscription paused by server. Run: npx @rulecatch/ai-pooler reactivate');
1077
+ // Don't record as failure - it's a subscription issue, not a transient error
1078
+ break;
1079
+ }
1080
+ // Failure - record and stop
1081
+ state = recordFailure(state, result.status, result.retryAfter);
1082
+ log(`Batch failed, stopping flush. ${events.length - currentIndex} events remaining.`);
1083
+ break;
1084
+ }
1085
+ }
1086
+ const remaining = events.length - totalSent;
1087
+ state = updatePendingCount(state, remaining);
1088
+ saveState(state);
1089
+ log(`Flush complete: sent ${totalSent}/${events.length} events, ${remaining} remaining`);
1090
+ if (totalSent > 0) {
1091
+ console.log(`Sent ${totalSent} events${remaining > 0 ? ` (${remaining} remaining)` : ''}`);
1092
+ }
1093
+ }
1094
+ catch (err) {
1095
+ log(`Flush error: ${err}`);
1096
+ state = recordFailure(state, 0);
1097
+ saveState(state);
1098
+ // Report error to API
1099
+ await reportError(err, 'send', config);
1100
+ }
1101
+ finally {
1102
+ releaseLock();
1103
+ }
1104
+ }
1105
+ // Track config globally for error reporting in fatal handler
1106
+ let globalConfig = null;
1107
+ main().catch(async (err) => {
1108
+ log(`Fatal: ${err}`);
1109
+ // Report fatal error before exiting
1110
+ await reportError(err, 'unknown', globalConfig);
1111
+ releaseLock();
1112
+ process.exit(1);
1113
+ });
1114
+ //# sourceMappingURL=flush.js.map