@qwickapps/qwickbrain-proxy 1.0.2 → 1.1.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/CHANGELOG.md +17 -0
- package/dist/db/schema.d.ts +63 -6
- package/dist/db/schema.d.ts.map +1 -1
- package/dist/db/schema.js +17 -2
- package/dist/db/schema.js.map +1 -1
- package/dist/lib/__tests__/cache-manager.test.js +146 -83
- package/dist/lib/__tests__/cache-manager.test.js.map +1 -1
- package/dist/lib/__tests__/connection-manager.test.js +2 -2
- package/dist/lib/__tests__/connection-manager.test.js.map +1 -1
- package/dist/lib/__tests__/proxy-server.test.js +16 -44
- package/dist/lib/__tests__/proxy-server.test.js.map +1 -1
- package/dist/lib/__tests__/qwickbrain-client.test.js +3 -1
- package/dist/lib/__tests__/qwickbrain-client.test.js.map +1 -1
- package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts +2 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.d.ts.map +1 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.js +245 -0
- package/dist/lib/__tests__/sse-invalidation-listener.test.js.map +1 -0
- package/dist/lib/__tests__/write-queue-manager.test.d.ts +2 -0
- package/dist/lib/__tests__/write-queue-manager.test.d.ts.map +1 -0
- package/dist/lib/__tests__/write-queue-manager.test.js +291 -0
- package/dist/lib/__tests__/write-queue-manager.test.js.map +1 -0
- package/dist/lib/cache-manager.d.ts +35 -6
- package/dist/lib/cache-manager.d.ts.map +1 -1
- package/dist/lib/cache-manager.js +154 -41
- package/dist/lib/cache-manager.js.map +1 -1
- package/dist/lib/connection-manager.d.ts +7 -0
- package/dist/lib/connection-manager.d.ts.map +1 -1
- package/dist/lib/connection-manager.js +57 -8
- package/dist/lib/connection-manager.js.map +1 -1
- package/dist/lib/proxy-server.d.ts +12 -0
- package/dist/lib/proxy-server.d.ts.map +1 -1
- package/dist/lib/proxy-server.js +184 -87
- package/dist/lib/proxy-server.js.map +1 -1
- package/dist/lib/qwickbrain-client.d.ts +4 -0
- package/dist/lib/qwickbrain-client.d.ts.map +1 -1
- package/dist/lib/qwickbrain-client.js +152 -13
- package/dist/lib/qwickbrain-client.js.map +1 -1
- package/dist/lib/sse-invalidation-listener.d.ts +31 -0
- package/dist/lib/sse-invalidation-listener.d.ts.map +1 -0
- package/dist/lib/sse-invalidation-listener.js +151 -0
- package/dist/lib/sse-invalidation-listener.js.map +1 -0
- package/dist/lib/tools.d.ts +21 -0
- package/dist/lib/tools.d.ts.map +1 -0
- package/dist/lib/tools.js +513 -0
- package/dist/lib/tools.js.map +1 -0
- package/dist/lib/write-queue-manager.d.ts +88 -0
- package/dist/lib/write-queue-manager.d.ts.map +1 -0
- package/dist/lib/write-queue-manager.js +191 -0
- package/dist/lib/write-queue-manager.js.map +1 -0
- package/dist/types/config.d.ts +7 -42
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js +1 -6
- package/dist/types/config.js.map +1 -1
- package/drizzle/0002_lru_cache_migration.sql +94 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +6 -2
- package/scripts/rebuild-sqlite.sh +26 -0
- package/src/db/schema.ts +17 -2
- package/src/lib/__tests__/cache-manager.test.ts +180 -90
- package/src/lib/__tests__/connection-manager.test.ts +2 -2
- package/src/lib/__tests__/proxy-server.test.ts +16 -51
- package/src/lib/__tests__/qwickbrain-client.test.ts +3 -1
- package/src/lib/__tests__/sse-invalidation-listener.test.ts +326 -0
- package/src/lib/__tests__/write-queue-manager.test.ts +383 -0
- package/src/lib/cache-manager.ts +198 -46
- package/src/lib/connection-manager.ts +67 -8
- package/src/lib/proxy-server.ts +231 -90
- package/src/lib/qwickbrain-client.ts +166 -12
- package/src/lib/sse-invalidation-listener.ts +185 -0
- package/src/lib/tools.ts +525 -0
- package/src/lib/write-queue-manager.ts +271 -0
- package/src/types/config.ts +1 -6
- package/.github/workflows/publish.yml +0 -92
package/src/lib/cache-manager.ts
CHANGED
|
@@ -1,30 +1,103 @@
|
|
|
1
|
-
import { eq, and,
|
|
1
|
+
import { eq, and, sql, desc } from 'drizzle-orm';
|
|
2
2
|
import type { DB } from '../db/client.js';
|
|
3
3
|
import { documents, memories } from '../db/schema.js';
|
|
4
4
|
import type { Config } from '../types/config.js';
|
|
5
5
|
|
|
6
|
+
// Critical document types - never evicted, not counted toward storage limit
|
|
7
|
+
const CRITICAL_DOC_TYPES = ['workflow', 'rule', 'agent', 'template'];
|
|
8
|
+
|
|
6
9
|
interface CachedItem<T> {
|
|
7
10
|
data: T;
|
|
8
11
|
cachedAt: Date;
|
|
9
|
-
expiresAt: Date;
|
|
10
12
|
age: number; // seconds
|
|
11
|
-
isExpired: boolean;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export class CacheManager {
|
|
16
|
+
private maxDynamicCacheSize: number; // Storage limit for dynamic tier only (bytes)
|
|
17
|
+
|
|
15
18
|
constructor(
|
|
16
19
|
private db: DB,
|
|
17
20
|
private config: Config['cache']
|
|
18
|
-
) {
|
|
21
|
+
) {
|
|
22
|
+
// Default to 100MB if not configured
|
|
23
|
+
this.maxDynamicCacheSize = config.maxCacheSizeBytes || 100 * 1024 * 1024;
|
|
24
|
+
}
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Get current size of dynamic tier cache (excludes critical tier)
|
|
28
|
+
*/
|
|
29
|
+
private async getDynamicCacheSize(): Promise<number> {
|
|
30
|
+
const result = await this.db
|
|
31
|
+
.select({ total: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0) + COALESCE((SELECT sum(${memories.sizeBytes}) FROM ${memories}), 0)` })
|
|
32
|
+
.from(documents)
|
|
33
|
+
.where(eq(documents.isCritical, false));
|
|
34
|
+
|
|
35
|
+
return result[0]?.total || 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Evict LRU entries from dynamic tier to free up space
|
|
40
|
+
* NEVER touches critical tier
|
|
41
|
+
*/
|
|
42
|
+
private async evictLRU(bytesToFree: number): Promise<void> {
|
|
43
|
+
let freed = 0;
|
|
44
|
+
|
|
45
|
+
// Evict from documents (dynamic tier only)
|
|
46
|
+
const docCandidates = await this.db
|
|
47
|
+
.select()
|
|
48
|
+
.from(documents)
|
|
49
|
+
.where(eq(documents.isCritical, false))
|
|
50
|
+
.orderBy(documents.lastAccessedAt) // ASC = oldest first
|
|
51
|
+
.limit(100);
|
|
52
|
+
|
|
53
|
+
for (const doc of docCandidates) {
|
|
54
|
+
if (freed >= bytesToFree) break;
|
|
55
|
+
|
|
56
|
+
await this.db.delete(documents).where(eq(documents.id, doc.id));
|
|
57
|
+
freed += doc.sizeBytes;
|
|
58
|
+
console.error(`LRU evicted document: ${doc.docType}:${doc.name} (${doc.sizeBytes} bytes)`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Evict from memories if needed
|
|
62
|
+
if (freed < bytesToFree) {
|
|
63
|
+
const memCandidates = await this.db
|
|
64
|
+
.select()
|
|
65
|
+
.from(memories)
|
|
66
|
+
.orderBy(memories.lastAccessedAt) // ASC = oldest first
|
|
67
|
+
.limit(100);
|
|
26
68
|
|
|
27
|
-
|
|
69
|
+
for (const mem of memCandidates) {
|
|
70
|
+
if (freed >= bytesToFree) break;
|
|
71
|
+
|
|
72
|
+
await this.db.delete(memories).where(eq(memories.id, mem.id));
|
|
73
|
+
freed += mem.sizeBytes;
|
|
74
|
+
console.error(`LRU evicted memory: ${mem.name} (${mem.sizeBytes} bytes)`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.error(`LRU eviction complete: freed ${freed} bytes`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ensure sufficient cache space
|
|
83
|
+
* Critical items bypass this check
|
|
84
|
+
*/
|
|
85
|
+
private async ensureCacheSize(requiredBytes: number, isCritical: boolean): Promise<void> {
|
|
86
|
+
// Critical files bypass storage limit check
|
|
87
|
+
if (isCritical) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Only count dynamic tier toward storage limit
|
|
92
|
+
const currentSize = await this.getDynamicCacheSize();
|
|
93
|
+
if (currentSize + requiredBytes <= this.maxDynamicCacheSize) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Evict LRU entries from dynamic tier only
|
|
98
|
+
const toEvict = currentSize + requiredBytes - this.maxDynamicCacheSize;
|
|
99
|
+
console.error(`Cache size limit reached: ${currentSize} + ${requiredBytes} > ${this.maxDynamicCacheSize}, evicting ${toEvict} bytes`);
|
|
100
|
+
await this.evictLRU(toEvict);
|
|
28
101
|
}
|
|
29
102
|
|
|
30
103
|
async getDocument(docType: string, name: string, project?: string): Promise<CachedItem<any> | null> {
|
|
@@ -46,10 +119,13 @@ export class CacheManager {
|
|
|
46
119
|
return null;
|
|
47
120
|
}
|
|
48
121
|
|
|
122
|
+
// Update last accessed timestamp for LRU tracking
|
|
49
123
|
const now = new Date();
|
|
124
|
+
await this.db.update(documents)
|
|
125
|
+
.set({ lastAccessedAt: now })
|
|
126
|
+
.where(eq(documents.id, cached.id));
|
|
127
|
+
|
|
50
128
|
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
51
|
-
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
52
|
-
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
53
129
|
|
|
54
130
|
return {
|
|
55
131
|
data: {
|
|
@@ -60,9 +136,7 @@ export class CacheManager {
|
|
|
60
136
|
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
61
137
|
},
|
|
62
138
|
cachedAt: cached.cachedAt,
|
|
63
|
-
expiresAt: cached.expiresAt,
|
|
64
139
|
age,
|
|
65
|
-
isExpired,
|
|
66
140
|
};
|
|
67
141
|
}
|
|
68
142
|
|
|
@@ -74,12 +148,12 @@ export class CacheManager {
|
|
|
74
148
|
metadata?: Record<string, unknown>
|
|
75
149
|
): Promise<void> {
|
|
76
150
|
const now = new Date();
|
|
77
|
-
const ttl = this.getTTL('get_document');
|
|
78
|
-
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
79
|
-
|
|
80
|
-
// Use empty string instead of null for project to make unique constraint work
|
|
81
|
-
// SQLite treats NULL as distinct values in unique constraints
|
|
82
151
|
const projectValue = project || '';
|
|
152
|
+
const isCritical = CRITICAL_DOC_TYPES.includes(docType);
|
|
153
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
154
|
+
|
|
155
|
+
// Ensure space available (skips check if critical)
|
|
156
|
+
await this.ensureCacheSize(sizeBytes, isCritical);
|
|
83
157
|
|
|
84
158
|
await this.db
|
|
85
159
|
.insert(documents)
|
|
@@ -90,7 +164,9 @@ export class CacheManager {
|
|
|
90
164
|
content,
|
|
91
165
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
92
166
|
cachedAt: now,
|
|
93
|
-
|
|
167
|
+
lastAccessedAt: now,
|
|
168
|
+
isCritical,
|
|
169
|
+
sizeBytes,
|
|
94
170
|
synced: true,
|
|
95
171
|
})
|
|
96
172
|
.onConflictDoUpdate({
|
|
@@ -98,8 +174,8 @@ export class CacheManager {
|
|
|
98
174
|
set: {
|
|
99
175
|
content,
|
|
100
176
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
101
|
-
|
|
102
|
-
|
|
177
|
+
lastAccessedAt: now,
|
|
178
|
+
sizeBytes,
|
|
103
179
|
synced: true,
|
|
104
180
|
},
|
|
105
181
|
});
|
|
@@ -123,10 +199,13 @@ export class CacheManager {
|
|
|
123
199
|
return null;
|
|
124
200
|
}
|
|
125
201
|
|
|
202
|
+
// Update last accessed timestamp for LRU tracking
|
|
126
203
|
const now = new Date();
|
|
204
|
+
await this.db.update(memories)
|
|
205
|
+
.set({ lastAccessedAt: now })
|
|
206
|
+
.where(eq(memories.id, cached.id));
|
|
207
|
+
|
|
127
208
|
const age = Math.floor((now.getTime() - cached.cachedAt.getTime()) / 1000);
|
|
128
|
-
// Fix: Compare timestamp values explicitly to avoid Date comparison issues
|
|
129
|
-
const isExpired = now.getTime() > cached.expiresAt.getTime();
|
|
130
209
|
|
|
131
210
|
return {
|
|
132
211
|
data: {
|
|
@@ -136,9 +215,7 @@ export class CacheManager {
|
|
|
136
215
|
metadata: cached.metadata ? JSON.parse(cached.metadata) : {},
|
|
137
216
|
},
|
|
138
217
|
cachedAt: cached.cachedAt,
|
|
139
|
-
expiresAt: cached.expiresAt,
|
|
140
218
|
age,
|
|
141
|
-
isExpired,
|
|
142
219
|
};
|
|
143
220
|
}
|
|
144
221
|
|
|
@@ -149,11 +226,11 @@ export class CacheManager {
|
|
|
149
226
|
metadata?: Record<string, unknown>
|
|
150
227
|
): Promise<void> {
|
|
151
228
|
const now = new Date();
|
|
152
|
-
const ttl = this.getTTL('get_memory');
|
|
153
|
-
const expiresAt = new Date(now.getTime() + ttl * 1000);
|
|
154
|
-
|
|
155
|
-
// Use empty string instead of null for project to make unique constraint work
|
|
156
229
|
const projectValue = project || '';
|
|
230
|
+
const sizeBytes = Buffer.byteLength(content, 'utf8');
|
|
231
|
+
|
|
232
|
+
// Memories are always dynamic tier (not critical)
|
|
233
|
+
await this.ensureCacheSize(sizeBytes, false);
|
|
157
234
|
|
|
158
235
|
await this.db
|
|
159
236
|
.insert(memories)
|
|
@@ -163,7 +240,8 @@ export class CacheManager {
|
|
|
163
240
|
content,
|
|
164
241
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
165
242
|
cachedAt: now,
|
|
166
|
-
|
|
243
|
+
lastAccessedAt: now,
|
|
244
|
+
sizeBytes,
|
|
167
245
|
synced: true,
|
|
168
246
|
})
|
|
169
247
|
.onConflictDoUpdate({
|
|
@@ -171,31 +249,105 @@ export class CacheManager {
|
|
|
171
249
|
set: {
|
|
172
250
|
content,
|
|
173
251
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
174
|
-
|
|
175
|
-
|
|
252
|
+
lastAccessedAt: now,
|
|
253
|
+
sizeBytes,
|
|
176
254
|
synced: true,
|
|
177
255
|
},
|
|
178
256
|
});
|
|
179
257
|
}
|
|
180
258
|
|
|
181
|
-
|
|
182
|
-
|
|
259
|
+
/**
|
|
260
|
+
* Invalidate a document from cache (for SSE-based cache invalidation)
|
|
261
|
+
*/
|
|
262
|
+
async invalidateDocument(docType: string, name: string, project?: string): Promise<void> {
|
|
263
|
+
const projectValue = project || '';
|
|
183
264
|
|
|
184
|
-
|
|
185
|
-
const deletedDocs = await this.db
|
|
265
|
+
await this.db
|
|
186
266
|
.delete(documents)
|
|
187
|
-
.where(
|
|
188
|
-
|
|
267
|
+
.where(
|
|
268
|
+
and(
|
|
269
|
+
eq(documents.docType, docType),
|
|
270
|
+
eq(documents.name, name),
|
|
271
|
+
eq(documents.project, projectValue)
|
|
272
|
+
)
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
console.error(`Cache invalidated: ${docType}:${name}`);
|
|
276
|
+
}
|
|
189
277
|
|
|
190
|
-
|
|
191
|
-
|
|
278
|
+
/**
|
|
279
|
+
* Invalidate a memory from cache (for SSE-based cache invalidation)
|
|
280
|
+
*/
|
|
281
|
+
async invalidateMemory(name: string, project?: string): Promise<void> {
|
|
282
|
+
const projectValue = project || '';
|
|
283
|
+
|
|
284
|
+
await this.db
|
|
192
285
|
.delete(memories)
|
|
193
|
-
.where(
|
|
194
|
-
|
|
286
|
+
.where(
|
|
287
|
+
and(
|
|
288
|
+
eq(memories.name, name),
|
|
289
|
+
eq(memories.project, projectValue)
|
|
290
|
+
)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
console.error(`Cache invalidated: memory:${name}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Get cache statistics
|
|
298
|
+
*/
|
|
299
|
+
async getCacheStats(): Promise<{
|
|
300
|
+
criticalSize: number;
|
|
301
|
+
criticalCount: number;
|
|
302
|
+
dynamicSize: number;
|
|
303
|
+
dynamicCount: number;
|
|
304
|
+
totalSize: number;
|
|
305
|
+
totalCount: number;
|
|
306
|
+
memorySize: number;
|
|
307
|
+
memoryCount: number;
|
|
308
|
+
}> {
|
|
309
|
+
// Critical documents
|
|
310
|
+
const criticalResult = await this.db
|
|
311
|
+
.select({
|
|
312
|
+
size: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0)`,
|
|
313
|
+
count: sql<number>`count(*)`
|
|
314
|
+
})
|
|
315
|
+
.from(documents)
|
|
316
|
+
.where(eq(documents.isCritical, true));
|
|
317
|
+
|
|
318
|
+
// Dynamic documents
|
|
319
|
+
const dynamicResult = await this.db
|
|
320
|
+
.select({
|
|
321
|
+
size: sql<number>`COALESCE(sum(${documents.sizeBytes}), 0)`,
|
|
322
|
+
count: sql<number>`count(*)`
|
|
323
|
+
})
|
|
324
|
+
.from(documents)
|
|
325
|
+
.where(eq(documents.isCritical, false));
|
|
326
|
+
|
|
327
|
+
// Memories
|
|
328
|
+
const memoryResult = await this.db
|
|
329
|
+
.select({
|
|
330
|
+
size: sql<number>`COALESCE(sum(${memories.sizeBytes}), 0)`,
|
|
331
|
+
count: sql<number>`count(*)`
|
|
332
|
+
})
|
|
333
|
+
.from(memories);
|
|
334
|
+
|
|
335
|
+
const criticalSize = criticalResult[0]?.size || 0;
|
|
336
|
+
const criticalCount = criticalResult[0]?.count || 0;
|
|
337
|
+
const dynamicSize = dynamicResult[0]?.size || 0;
|
|
338
|
+
const dynamicCount = dynamicResult[0]?.count || 0;
|
|
339
|
+
const memorySize = memoryResult[0]?.size || 0;
|
|
340
|
+
const memoryCount = memoryResult[0]?.count || 0;
|
|
195
341
|
|
|
196
342
|
return {
|
|
197
|
-
|
|
198
|
-
|
|
343
|
+
criticalSize,
|
|
344
|
+
criticalCount,
|
|
345
|
+
dynamicSize: dynamicSize + memorySize,
|
|
346
|
+
dynamicCount: dynamicCount + memoryCount,
|
|
347
|
+
totalSize: criticalSize + dynamicSize + memorySize,
|
|
348
|
+
totalCount: criticalCount + dynamicCount + memoryCount,
|
|
349
|
+
memorySize,
|
|
350
|
+
memoryCount,
|
|
199
351
|
};
|
|
200
352
|
}
|
|
201
353
|
}
|
|
@@ -3,6 +3,9 @@ import { ConnectionState } from '../types/mcp.js';
|
|
|
3
3
|
import type { Config } from '../types/config.js';
|
|
4
4
|
import type { QwickBrainClient } from './qwickbrain-client.js';
|
|
5
5
|
|
|
6
|
+
// Dormant mode: long interval health check after giving up on reconnection
|
|
7
|
+
const DORMANT_CHECK_INTERVAL = 300000; // 5 minutes
|
|
8
|
+
|
|
6
9
|
export class ConnectionManager extends EventEmitter {
|
|
7
10
|
private state: ConnectionState = 'disconnected';
|
|
8
11
|
private reconnectAttempts = 0;
|
|
@@ -11,6 +14,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
11
14
|
private qwickbrainClient: QwickBrainClient;
|
|
12
15
|
private config: Config['connection'];
|
|
13
16
|
private isStopped = false;
|
|
17
|
+
private isDormant = false;
|
|
14
18
|
private executionLock: Promise<void> = Promise.resolve();
|
|
15
19
|
|
|
16
20
|
constructor(qwickbrainClient: QwickBrainClient, config: Config['connection']) {
|
|
@@ -34,6 +38,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
34
38
|
|
|
35
39
|
async start(): Promise<void> {
|
|
36
40
|
this.isStopped = false;
|
|
41
|
+
this.isDormant = false;
|
|
37
42
|
// Start health check in background (don't block server startup)
|
|
38
43
|
this.healthCheck().catch(err => {
|
|
39
44
|
console.error('Initial health check error:', err);
|
|
@@ -43,6 +48,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
43
48
|
|
|
44
49
|
stop(): void {
|
|
45
50
|
this.isStopped = true;
|
|
51
|
+
this.isDormant = false;
|
|
46
52
|
if (this.healthCheckTimer) {
|
|
47
53
|
clearTimeout(this.healthCheckTimer);
|
|
48
54
|
this.healthCheckTimer = null;
|
|
@@ -62,25 +68,33 @@ export class ConnectionManager extends EventEmitter {
|
|
|
62
68
|
const latencyMs = Date.now() - startTime;
|
|
63
69
|
|
|
64
70
|
if (isHealthy) {
|
|
71
|
+
const wasDormant = this.isDormant;
|
|
72
|
+
this.isDormant = false;
|
|
65
73
|
this.setState('connected');
|
|
66
74
|
this.reconnectAttempts = 0;
|
|
67
75
|
this.clearReconnectTimer();
|
|
68
76
|
this.emit('connected', { latencyMs });
|
|
77
|
+
|
|
78
|
+
// If waking from dormant, switch back to normal health check interval
|
|
79
|
+
if (wasDormant) {
|
|
80
|
+
this.scheduleHealthCheck();
|
|
81
|
+
}
|
|
69
82
|
return true;
|
|
70
83
|
} else {
|
|
71
84
|
throw new Error('Health check failed');
|
|
72
85
|
}
|
|
73
86
|
} catch (error) {
|
|
74
87
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
75
|
-
this.
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
if (!this.isDormant) {
|
|
89
|
+
this.setState('disconnected');
|
|
90
|
+
this.emit('disconnected', { error: errorMessage });
|
|
91
|
+
this.scheduleReconnect();
|
|
92
|
+
}
|
|
78
93
|
return false;
|
|
79
94
|
}
|
|
80
95
|
}
|
|
81
96
|
|
|
82
97
|
private scheduleHealthCheck(): void {
|
|
83
|
-
// Don't schedule if stopped
|
|
84
98
|
if (this.isStopped) {
|
|
85
99
|
return;
|
|
86
100
|
}
|
|
@@ -89,13 +103,14 @@ export class ConnectionManager extends EventEmitter {
|
|
|
89
103
|
clearTimeout(this.healthCheckTimer);
|
|
90
104
|
}
|
|
91
105
|
|
|
106
|
+
const interval = this.isDormant ? DORMANT_CHECK_INTERVAL : this.config.healthCheckInterval;
|
|
107
|
+
|
|
92
108
|
this.healthCheckTimer = setTimeout(async () => {
|
|
93
109
|
await this.healthCheck();
|
|
94
|
-
// Check again before rescheduling to prevent leak after stop()
|
|
95
110
|
if (!this.isStopped) {
|
|
96
111
|
this.scheduleHealthCheck();
|
|
97
112
|
}
|
|
98
|
-
},
|
|
113
|
+
}, interval);
|
|
99
114
|
}
|
|
100
115
|
|
|
101
116
|
private scheduleReconnect(): void {
|
|
@@ -104,8 +119,7 @@ export class ConnectionManager extends EventEmitter {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
107
|
-
this.
|
|
108
|
-
this.emit('maxReconnectAttemptsReached');
|
|
122
|
+
this.enterDormantMode();
|
|
109
123
|
return;
|
|
110
124
|
}
|
|
111
125
|
|
|
@@ -126,6 +140,51 @@ export class ConnectionManager extends EventEmitter {
|
|
|
126
140
|
this.emit('reconnecting', { attempt: this.reconnectAttempts, delay });
|
|
127
141
|
}
|
|
128
142
|
|
|
143
|
+
private enterDormantMode(): void {
|
|
144
|
+
this.isDormant = true;
|
|
145
|
+
this.setState('offline');
|
|
146
|
+
this.emit('dormant');
|
|
147
|
+
|
|
148
|
+
// Stop active health check and reconnect timers
|
|
149
|
+
if (this.healthCheckTimer) {
|
|
150
|
+
clearTimeout(this.healthCheckTimer);
|
|
151
|
+
this.healthCheckTimer = null;
|
|
152
|
+
}
|
|
153
|
+
if (this.reconnectTimer) {
|
|
154
|
+
clearTimeout(this.reconnectTimer);
|
|
155
|
+
this.reconnectTimer = null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Schedule dormant-mode health check (5 min interval)
|
|
159
|
+
console.error(`Entering dormant mode. Will retry every ${DORMANT_CHECK_INTERVAL / 1000}s.`);
|
|
160
|
+
this.scheduleHealthCheck();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Wake from dormant mode and attempt immediate reconnection.
|
|
165
|
+
* Called when a tool is invoked and we need to try connecting again.
|
|
166
|
+
*/
|
|
167
|
+
wakeUp(): void {
|
|
168
|
+
if (!this.isDormant || this.isStopped) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.error('Waking from dormant mode for tool call...');
|
|
173
|
+
this.isDormant = false;
|
|
174
|
+
this.reconnectAttempts = 0;
|
|
175
|
+
|
|
176
|
+
// Clear dormant timer and do immediate health check
|
|
177
|
+
if (this.healthCheckTimer) {
|
|
178
|
+
clearTimeout(this.healthCheckTimer);
|
|
179
|
+
this.healthCheckTimer = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.healthCheck().catch(err => {
|
|
183
|
+
console.error('Wake-up health check error:', err);
|
|
184
|
+
});
|
|
185
|
+
this.scheduleHealthCheck();
|
|
186
|
+
}
|
|
187
|
+
|
|
129
188
|
private clearReconnectTimer(): void {
|
|
130
189
|
if (this.reconnectTimer) {
|
|
131
190
|
clearTimeout(this.reconnectTimer);
|