@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/README.md +232 -0
- package/dist/cli.js +1338 -0
- package/dist/cli.js.map +1 -0
- package/dist/flush.js +1114 -0
- package/dist/flush.js.map +1 -0
- package/dist/index.cjs +1278 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +625 -0
- package/dist/index.js +1240 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/templates/rulecatch-track.sh +549 -0
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
|