@kb-labs/gateway-app 0.2.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.
Files changed (46) hide show
  1. package/.kb/database/kb.sqlite-shm +0 -0
  2. package/.kb/database/kb.sqlite-wal +0 -0
  3. package/package.json +49 -0
  4. package/src/__tests__/auth-routes.test.ts +279 -0
  5. package/src/__tests__/execute-routes.test.ts +408 -0
  6. package/src/__tests__/execution-registry.test.ts +218 -0
  7. package/src/__tests__/health.test.ts +215 -0
  8. package/src/__tests__/live-gateway.e2e.test.ts +648 -0
  9. package/src/__tests__/llm-gateway.test.ts +361 -0
  10. package/src/__tests__/observability-collector.test.ts +59 -0
  11. package/src/__tests__/platform-api.test.ts +317 -0
  12. package/src/__tests__/registry.test.ts +546 -0
  13. package/src/__tests__/retry-executor.test.ts +244 -0
  14. package/src/__tests__/server.integration.test.ts +417 -0
  15. package/src/__tests__/subscription-registry.test.ts +308 -0
  16. package/src/__tests__/telemetry-ingest.test.ts +309 -0
  17. package/src/__tests__/tokens.test.ts +83 -0
  18. package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
  19. package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
  20. package/src/auth/middleware.ts +50 -0
  21. package/src/auth/routes.ts +57 -0
  22. package/src/auth/tokens.ts +41 -0
  23. package/src/bootstrap.ts +98 -0
  24. package/src/clients/subscription-registry.ts +137 -0
  25. package/src/clients/ws-handler.ts +196 -0
  26. package/src/config.ts +20 -0
  27. package/src/docs/routes.ts +70 -0
  28. package/src/execute/errors.ts +21 -0
  29. package/src/execute/execution-registry.ts +84 -0
  30. package/src/execute/retry-executor.ts +159 -0
  31. package/src/execute/routes.ts +239 -0
  32. package/src/hosts/dispatcher.ts +2 -0
  33. package/src/hosts/registry.ts +305 -0
  34. package/src/hosts/ws-handler.ts +445 -0
  35. package/src/index.ts +7 -0
  36. package/src/llm/routes.ts +343 -0
  37. package/src/manifest.ts +21 -0
  38. package/src/observability/collector.ts +346 -0
  39. package/src/platform/routes.ts +195 -0
  40. package/src/server.ts +447 -0
  41. package/src/telemetry/routes.ts +89 -0
  42. package/src/ws/gateway-ws.ts +73 -0
  43. package/tsconfig.build.json +15 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +8 -0
  46. package/vitest.config.ts +23 -0
@@ -0,0 +1,546 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { HostRegistry } from '../hosts/registry.js';
3
+ import type { ICache } from '@kb-labs/core-platform';
4
+ import type { HostDescriptor, IHostStore } from '@kb-labs/gateway-contracts';
5
+
6
+ function makeCache(): { cache: ICache; store: Map<string, unknown> } {
7
+ const store = new Map<string, unknown>();
8
+ const cache: ICache = {
9
+ get: vi.fn(async (key: string) => store.get(key) ?? null),
10
+ set: vi.fn(async (key: string, value: unknown) => { store.set(key, value); }),
11
+ delete: vi.fn(async (key: string) => { store.delete(key); }),
12
+ clear: vi.fn(async () => { store.clear(); }),
13
+ } as unknown as ICache;
14
+ return { cache, store };
15
+ }
16
+
17
+ describe('HostRegistry.register', () => {
18
+ it('creates a new host descriptor with offline status', async () => {
19
+ const { cache } = makeCache();
20
+ const registry = new HostRegistry(cache);
21
+
22
+ const result = await registry.register({
23
+ name: 'laptop',
24
+ namespaceId: 'ns-1',
25
+ capabilities: ['filesystem', 'git'],
26
+ workspacePaths: ['/home/user/projects'],
27
+ });
28
+
29
+ expect(result.descriptor.name).toBe('laptop');
30
+ expect(result.descriptor.namespaceId).toBe('ns-1');
31
+ expect(result.descriptor.status).toBe('offline');
32
+ expect(result.descriptor.capabilities).toEqual(['filesystem', 'git']);
33
+ expect(result.descriptor.connections).toEqual([]);
34
+ expect(result.descriptor.hostId).toBeTypeOf('string');
35
+ expect(result.machineToken).toBeTypeOf('string');
36
+ expect(result.machineToken).not.toBe(result.descriptor.hostId);
37
+ });
38
+
39
+ it('stores descriptor and token in cache', async () => {
40
+ const { cache, store } = makeCache();
41
+ const registry = new HostRegistry(cache);
42
+
43
+ const result = await registry.register({
44
+ name: 'srv',
45
+ namespaceId: 'ns-2',
46
+ capabilities: ['filesystem'],
47
+ workspacePaths: [],
48
+ });
49
+
50
+ const registryKey = `host:registry:ns-2:${result.descriptor.hostId}`;
51
+ const tokenKey = `host:token:${result.machineToken}`;
52
+
53
+ expect(store.has(registryKey)).toBe(true);
54
+ expect(store.has(tokenKey)).toBe(true);
55
+
56
+ const tokenEntry = store.get(tokenKey) as { hostId: string; namespaceId: string };
57
+ expect(tokenEntry.hostId).toBe(result.descriptor.hostId);
58
+ expect(tokenEntry.namespaceId).toBe('ns-2');
59
+ });
60
+
61
+ it('generates unique hostId and token per registration', async () => {
62
+ const { cache } = makeCache();
63
+ const registry = new HostRegistry(cache);
64
+ const reg = { name: 'h', namespaceId: 'ns', capabilities: [] as [], workspacePaths: [] };
65
+
66
+ const r1 = await registry.register(reg);
67
+ const r2 = await registry.register(reg);
68
+
69
+ expect(r1.descriptor.hostId).not.toBe(r2.descriptor.hostId);
70
+ expect(r1.machineToken).not.toBe(r2.machineToken);
71
+ });
72
+ });
73
+
74
+ describe('HostRegistry.setOnline / setOffline', () => {
75
+ let cache: ICache;
76
+ let store: Map<string, unknown>;
77
+ let registry: HostRegistry;
78
+ let hostId: string;
79
+
80
+ beforeEach(async () => {
81
+ ({ cache, store } = makeCache());
82
+ registry = new HostRegistry(cache);
83
+ const result = await registry.register({
84
+ name: 'test-host', namespaceId: 'ns-1', capabilities: ['filesystem'], workspacePaths: [],
85
+ });
86
+ hostId = result.descriptor.hostId;
87
+ });
88
+
89
+ it('sets status to online and adds connectionId', async () => {
90
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
91
+ const host = store.get(`host:registry:ns-1:${hostId}`) as HostDescriptor;
92
+ expect(host.status).toBe('online');
93
+ expect(host.connections).toContain('conn-a');
94
+ });
95
+
96
+ it('deduplicates connections on repeated setOnline', async () => {
97
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
98
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
99
+ const host = store.get(`host:registry:ns-1:${hostId}`) as HostDescriptor;
100
+ expect(host.connections).toHaveLength(1);
101
+ });
102
+
103
+ it('each setOnline replaces connections with the new connectionId', async () => {
104
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
105
+ await registry.setOnline(hostId, 'ns-1', 'conn-b');
106
+ const host = store.get(`host:registry:ns-1:${hostId}`) as HostDescriptor;
107
+ // New connection replaces stale ones — no accumulation across reconnects
108
+ expect(host.connections).toEqual(['conn-b']);
109
+ expect(host.status).toBe('online');
110
+ });
111
+
112
+ it('enters reconnecting status when last connection removed (grace period)', async () => {
113
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
114
+ await registry.setOffline(hostId, 'ns-1', 'conn-a');
115
+ const host = store.get(`host:registry:ns-1:${hostId}`) as HostDescriptor;
116
+ expect(host.status).toBe('reconnecting');
117
+ expect(host.connections).toHaveLength(0);
118
+ });
119
+
120
+ it('stays online when disconnected connectionId is not the current one', async () => {
121
+ // setOnline(conn-b) replaces list → connections=['conn-b']
122
+ // setOffline(conn-a) removes conn-a which is gone → connections=['conn-b'] → still online
123
+ await registry.setOnline(hostId, 'ns-1', 'conn-a');
124
+ await registry.setOnline(hostId, 'ns-1', 'conn-b');
125
+ await registry.setOffline(hostId, 'ns-1', 'conn-a');
126
+ const host = store.get(`host:registry:ns-1:${hostId}`) as HostDescriptor;
127
+ expect(host.status).toBe('online');
128
+ expect(host.connections).toEqual(['conn-b']);
129
+ });
130
+
131
+ it('does nothing if host not found', async () => {
132
+ await expect(registry.setOnline('nonexistent', 'ns-1', 'conn-x')).resolves.toBeUndefined();
133
+ await expect(registry.setOffline('nonexistent', 'ns-1', 'conn-x')).resolves.toBeUndefined();
134
+ });
135
+ });
136
+
137
+ describe('HostRegistry.heartbeat', () => {
138
+ it('updates lastSeen timestamp', async () => {
139
+ const { cache, store } = makeCache();
140
+ const registry = new HostRegistry(cache);
141
+ const before = Date.now();
142
+
143
+ const { descriptor } = await registry.register({
144
+ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [],
145
+ });
146
+
147
+ await new Promise((r) => { setTimeout(r, 5); }); // small delay
148
+ await registry.heartbeat(descriptor.hostId, 'ns');
149
+
150
+ const host = store.get(`host:registry:ns:${descriptor.hostId}`) as HostDescriptor;
151
+ expect(host.lastSeen).toBeGreaterThan(before);
152
+ });
153
+ });
154
+
155
+ describe('HostRegistry.get', () => {
156
+ it('returns host descriptor by id', async () => {
157
+ const { cache } = makeCache();
158
+ const registry = new HostRegistry(cache);
159
+ const { descriptor } = await registry.register({
160
+ name: 'h', namespaceId: 'ns', capabilities: [], workspacePaths: [],
161
+ });
162
+
163
+ const found = await registry.get(descriptor.hostId, 'ns');
164
+ expect(found).not.toBeNull();
165
+ expect(found!.hostId).toBe(descriptor.hostId);
166
+ });
167
+
168
+ it('returns null for unknown host', async () => {
169
+ const { cache } = makeCache();
170
+ const registry = new HostRegistry(cache);
171
+ expect(await registry.get('ghost', 'ns')).toBeNull();
172
+ });
173
+ });
174
+
175
+ describe('HostRegistry.resolveToken', () => {
176
+ it('returns hostId/namespaceId for valid machine token', async () => {
177
+ const { cache } = makeCache();
178
+ const registry = new HostRegistry(cache);
179
+ const { machineToken, descriptor } = await registry.register({
180
+ name: 'h', namespaceId: 'ns-1', capabilities: [], workspacePaths: [],
181
+ });
182
+
183
+ const resolved = await registry.resolveToken(machineToken);
184
+ expect(resolved).not.toBeNull();
185
+ expect(resolved!.hostId).toBe(descriptor.hostId);
186
+ expect(resolved!.namespaceId).toBe('ns-1');
187
+ });
188
+
189
+ it('returns null for unknown token', async () => {
190
+ const { cache } = makeCache();
191
+ const registry = new HostRegistry(cache);
192
+ expect(await registry.resolveToken('bad-token')).toBeNull();
193
+ });
194
+ });
195
+
196
+ // ── Dual-layer tests (cache + store) ──────────────────────────────
197
+
198
+ function makeHostStore(): { hostStore: IHostStore; hosts: Map<string, HostDescriptor>; tokens: Map<string, { hostId: string; namespaceId: string }> } {
199
+ const hosts = new Map<string, HostDescriptor>();
200
+ const tokens = new Map<string, { hostId: string; namespaceId: string }>();
201
+
202
+ const hostStore: IHostStore = {
203
+ save: vi.fn(async (d: HostDescriptor) => { hosts.set(`${d.hostId}:${d.namespaceId}`, d); }),
204
+ get: vi.fn(async (hostId: string, ns: string) => hosts.get(`${hostId}:${ns}`) ?? null),
205
+ list: vi.fn(async (ns: string) => [...hosts.values()].filter(h => h.namespaceId === ns)),
206
+ listAll: vi.fn(async () => [...hosts.values()]),
207
+ delete: vi.fn(async (hostId: string, ns: string) => {
208
+ const key = `${hostId}:${ns}`;
209
+ if (!hosts.has(key)) {return false;}
210
+ hosts.delete(key);
211
+ // Also remove tokens for this host
212
+ for (const [tok, entry] of tokens) {
213
+ if (entry.hostId === hostId && entry.namespaceId === ns) {tokens.delete(tok);}
214
+ }
215
+ return true;
216
+ }),
217
+ saveToken: vi.fn(async (token: string, hostId: string, ns: string) => { tokens.set(token, { hostId, namespaceId: ns }); }),
218
+ resolveToken: vi.fn(async (token: string) => tokens.get(token) ?? null),
219
+ deleteToken: vi.fn(async (token: string) => { tokens.delete(token); }),
220
+ };
221
+
222
+ return { hostStore, hosts, tokens };
223
+ }
224
+
225
+ describe('HostRegistry with IHostStore (dual-layer)', () => {
226
+ describe('register', () => {
227
+ it('writes to both cache and store', async () => {
228
+ const { cache } = makeCache();
229
+ const { hostStore, hosts, tokens } = makeHostStore();
230
+ const registry = new HostRegistry(cache, hostStore);
231
+
232
+ const result = await registry.register({
233
+ name: 'dual-host', namespaceId: 'ns', capabilities: ['filesystem'], workspacePaths: [],
234
+ });
235
+
236
+ // Store has the host
237
+ expect(hosts.size).toBe(1);
238
+ const stored = [...hosts.values()][0]!;
239
+ expect(stored.hostId).toBe(result.descriptor.hostId);
240
+ expect(stored.name).toBe('dual-host');
241
+
242
+ // Store has the token
243
+ expect(tokens.size).toBe(1);
244
+ const tokenEntry = [...tokens.values()][0]!;
245
+ expect(tokenEntry.hostId).toBe(result.descriptor.hostId);
246
+
247
+ // store.save and store.saveToken were called
248
+ expect(hostStore.save).toHaveBeenCalledTimes(1);
249
+ expect(hostStore.saveToken).toHaveBeenCalledTimes(1);
250
+ });
251
+ });
252
+
253
+ describe('restore', () => {
254
+ it('loads all hosts from store into cache as offline', async () => {
255
+ const { cache, store: cacheMap } = makeCache();
256
+ const { hostStore, hosts } = makeHostStore();
257
+
258
+ // Pre-populate store with 2 hosts
259
+ const h1: HostDescriptor = {
260
+ hostId: 'h1', name: 'host-one', namespaceId: 'ns',
261
+ capabilities: ['filesystem'], status: 'online', lastSeen: 100,
262
+ connections: ['old-conn'], createdAt: 100, updatedAt: 100,
263
+ };
264
+ const h2: HostDescriptor = {
265
+ hostId: 'h2', name: 'host-two', namespaceId: 'ns',
266
+ capabilities: ['git'], status: 'online', lastSeen: 200,
267
+ connections: ['old-conn-2'], createdAt: 200, updatedAt: 200,
268
+ };
269
+ hosts.set('h1:ns', h1);
270
+ hosts.set('h2:ns', h2);
271
+
272
+ const registry = new HostRegistry(cache, hostStore);
273
+ const count = await registry.restore();
274
+
275
+ expect(count).toBe(2);
276
+
277
+ // Both in cache, but as offline with no connections
278
+ const cached1 = cacheMap.get('host:registry:ns:h1') as HostDescriptor;
279
+ expect(cached1.status).toBe('offline');
280
+ expect(cached1.connections).toEqual([]);
281
+ expect(cached1.name).toBe('host-one');
282
+
283
+ const cached2 = cacheMap.get('host:registry:ns:h2') as HostDescriptor;
284
+ expect(cached2.status).toBe('offline');
285
+ expect(cached2.connections).toEqual([]);
286
+ });
287
+
288
+ it('returns 0 when store is empty', async () => {
289
+ const { cache } = makeCache();
290
+ const { hostStore } = makeHostStore();
291
+ const registry = new HostRegistry(cache, hostStore);
292
+
293
+ expect(await registry.restore()).toBe(0);
294
+ });
295
+
296
+ it('returns 0 when no store provided', async () => {
297
+ const { cache } = makeCache();
298
+ const registry = new HostRegistry(cache);
299
+
300
+ expect(await registry.restore()).toBe(0);
301
+ });
302
+ });
303
+
304
+ describe('get (cache miss → store fallback)', () => {
305
+ it('falls through to store when cache misses', async () => {
306
+ const { cache, store: cacheMap } = makeCache();
307
+ const { hostStore, hosts } = makeHostStore();
308
+
309
+ // Host in store but NOT in cache
310
+ hosts.set('h1:ns', {
311
+ hostId: 'h1', name: 'stored-host', namespaceId: 'ns',
312
+ capabilities: ['filesystem'], status: 'online', lastSeen: 100,
313
+ connections: ['old'], createdAt: 100, updatedAt: 100,
314
+ });
315
+
316
+ const registry = new HostRegistry(cache, hostStore);
317
+ const result = await registry.get('h1', 'ns');
318
+
319
+ expect(result).not.toBeNull();
320
+ expect(result!.name).toBe('stored-host');
321
+ expect(result!.status).toBe('offline'); // always offline from store
322
+ expect(result!.connections).toEqual([]);
323
+
324
+ // Cache should now be warmed
325
+ expect(cacheMap.has('host:registry:ns:h1')).toBe(true);
326
+ });
327
+
328
+ it('returns null when both cache and store miss', async () => {
329
+ const { cache } = makeCache();
330
+ const { hostStore } = makeHostStore();
331
+ const registry = new HostRegistry(cache, hostStore);
332
+
333
+ expect(await registry.get('ghost', 'ns')).toBeNull();
334
+ });
335
+
336
+ it('prefers cache over store', async () => {
337
+ const { cache, store: cacheMap } = makeCache();
338
+ const { hostStore, hosts } = makeHostStore();
339
+
340
+ // Same host in both, different names
341
+ cacheMap.set('host:registry:ns:h1', {
342
+ hostId: 'h1', name: 'cache-version', namespaceId: 'ns',
343
+ capabilities: [], status: 'online', lastSeen: 200,
344
+ connections: ['live-conn'],
345
+ });
346
+ hosts.set('h1:ns', {
347
+ hostId: 'h1', name: 'store-version', namespaceId: 'ns',
348
+ capabilities: [], status: 'offline', lastSeen: 100,
349
+ connections: [], createdAt: 100, updatedAt: 100,
350
+ });
351
+
352
+ const registry = new HostRegistry(cache, hostStore);
353
+ const result = await registry.get('h1', 'ns');
354
+
355
+ expect(result!.name).toBe('cache-version');
356
+ expect(result!.status).toBe('online');
357
+ expect(hostStore.get).not.toHaveBeenCalled();
358
+ });
359
+ });
360
+
361
+ describe('resolveToken (cache miss → store fallback)', () => {
362
+ it('falls through to store when cache misses', async () => {
363
+ const { cache, store: cacheMap } = makeCache();
364
+ const { hostStore, tokens } = makeHostStore();
365
+
366
+ tokens.set('secret-tok', { hostId: 'h1', namespaceId: 'ns' });
367
+
368
+ const registry = new HostRegistry(cache, hostStore);
369
+ const resolved = await registry.resolveToken('secret-tok');
370
+
371
+ expect(resolved).toEqual({ hostId: 'h1', namespaceId: 'ns' });
372
+ // Cache warmed
373
+ expect(cacheMap.has('host:token:secret-tok')).toBe(true);
374
+ });
375
+ });
376
+
377
+ describe('list (store authoritative)', () => {
378
+ it('uses store as source of truth, enriches with cache status', async () => {
379
+ const { cache, store: cacheMap } = makeCache();
380
+ const { hostStore, hosts } = makeHostStore();
381
+
382
+ // 2 hosts in store
383
+ hosts.set('h1:ns', {
384
+ hostId: 'h1', name: 'one', namespaceId: 'ns',
385
+ capabilities: [], status: 'offline', lastSeen: 100,
386
+ connections: [], createdAt: 100, updatedAt: 100,
387
+ });
388
+ hosts.set('h2:ns', {
389
+ hostId: 'h2', name: 'two', namespaceId: 'ns',
390
+ capabilities: [], status: 'offline', lastSeen: 100,
391
+ connections: [], createdAt: 100, updatedAt: 100,
392
+ });
393
+
394
+ // h1 is online in cache
395
+ cacheMap.set('host:registry:ns:h1', {
396
+ hostId: 'h1', name: 'one', namespaceId: 'ns',
397
+ capabilities: [], status: 'online', lastSeen: 200,
398
+ connections: ['conn-live'],
399
+ });
400
+
401
+ const registry = new HostRegistry(cache, hostStore);
402
+ const result = await registry.list('ns');
403
+
404
+ expect(result).toHaveLength(2);
405
+
406
+ const h1 = result.find(h => h.hostId === 'h1')!;
407
+ expect(h1.status).toBe('online'); // enriched from cache
408
+
409
+ const h2 = result.find(h => h.hostId === 'h2')!;
410
+ expect(h2.status).toBe('offline'); // no cache entry
411
+ });
412
+ });
413
+
414
+ describe('deregister', () => {
415
+ it('removes from both cache and store', async () => {
416
+ const { cache, store: cacheMap } = makeCache();
417
+ const { hostStore, hosts } = makeHostStore();
418
+ const registry = new HostRegistry(cache, hostStore);
419
+
420
+ const { descriptor } = await registry.register({
421
+ name: 'doomed', namespaceId: 'ns', capabilities: [], workspacePaths: [],
422
+ });
423
+
424
+ expect(hosts.size).toBe(1);
425
+ expect(cacheMap.has(`host:registry:ns:${descriptor.hostId}`)).toBe(true);
426
+
427
+ const deleted = await registry.deregister(descriptor.hostId, 'ns');
428
+ expect(deleted).toBe(true);
429
+
430
+ // Gone from store
431
+ expect(hosts.size).toBe(0);
432
+ // Gone from cache
433
+ expect(cacheMap.has(`host:registry:ns:${descriptor.hostId}`)).toBe(false);
434
+ });
435
+
436
+ it('returns false for non-existent host', async () => {
437
+ const { cache } = makeCache();
438
+ const { hostStore } = makeHostStore();
439
+ const registry = new HostRegistry(cache, hostStore);
440
+
441
+ expect(await registry.deregister('ghost', 'ns')).toBe(false);
442
+ });
443
+ });
444
+
445
+ describe('ensureRegistered', () => {
446
+ it('persists to store when host does not exist', async () => {
447
+ const { cache } = makeCache();
448
+ const { hostStore, hosts } = makeHostStore();
449
+ const registry = new HostRegistry(cache, hostStore);
450
+
451
+ await registry.ensureRegistered('h1', 'ns', 'new-host', ['filesystem']);
452
+
453
+ expect(hosts.size).toBe(1);
454
+ expect(hosts.get('h1:ns')!.name).toBe('new-host');
455
+ expect(hostStore.save).toHaveBeenCalledTimes(1);
456
+ });
457
+
458
+ it('updates capabilities in store when they change', async () => {
459
+ const { cache } = makeCache();
460
+ const { hostStore, hosts } = makeHostStore();
461
+ const registry = new HostRegistry(cache, hostStore);
462
+
463
+ await registry.ensureRegistered('h1', 'ns', 'host', ['filesystem']);
464
+ await registry.ensureRegistered('h1', 'ns', 'host', ['filesystem', 'git']);
465
+
466
+ expect(hosts.get('h1:ns')!.capabilities).toEqual(['filesystem', 'git']);
467
+ expect(hostStore.save).toHaveBeenCalledTimes(2);
468
+ });
469
+ });
470
+ });
471
+
472
+ describe('HostRegistry grace period', () => {
473
+ it('sets status to reconnecting on last connection close', async () => {
474
+ const { cache, store: cacheMap } = makeCache();
475
+ const registry = new HostRegistry(cache, undefined, { reconnectGraceMs: 100 });
476
+
477
+ const { descriptor } = await registry.register({
478
+ name: 'grace-host', namespaceId: 'ns', capabilities: [], workspacePaths: [],
479
+ });
480
+ await registry.setOnline(descriptor.hostId, 'ns', 'conn-1');
481
+ await registry.setOffline(descriptor.hostId, 'ns', 'conn-1');
482
+
483
+ const host = cacheMap.get(`host:registry:ns:${descriptor.hostId}`) as HostDescriptor;
484
+ expect(host.status).toBe('reconnecting');
485
+ });
486
+
487
+ it('cancels grace timer when host reconnects before expiry', async () => {
488
+ const { cache, store: cacheMap } = makeCache();
489
+ const registry = new HostRegistry(cache, undefined, { reconnectGraceMs: 200 });
490
+
491
+ const { descriptor } = await registry.register({
492
+ name: 'grace-host', namespaceId: 'ns', capabilities: [], workspacePaths: [],
493
+ });
494
+ await registry.setOnline(descriptor.hostId, 'ns', 'conn-1');
495
+ await registry.setOffline(descriptor.hostId, 'ns', 'conn-1');
496
+
497
+ // Reconnect before grace expires
498
+ await registry.setOnline(descriptor.hostId, 'ns', 'conn-2');
499
+
500
+ const host = cacheMap.get(`host:registry:ns:${descriptor.hostId}`) as HostDescriptor;
501
+ expect(host.status).toBe('online');
502
+ expect(host.connections).toEqual(['conn-2']);
503
+
504
+ // Wait past grace — should still be online (timer was cancelled)
505
+ await new Promise(r => { setTimeout(r, 250); });
506
+ const after = cacheMap.get(`host:registry:ns:${descriptor.hostId}`) as HostDescriptor;
507
+ expect(after.status).toBe('online');
508
+ });
509
+
510
+ it('transitions to offline after grace period expires', async () => {
511
+ const { cache, store: cacheMap } = makeCache();
512
+ const registry = new HostRegistry(cache, undefined, { reconnectGraceMs: 50 });
513
+
514
+ const { descriptor } = await registry.register({
515
+ name: 'grace-host', namespaceId: 'ns', capabilities: [], workspacePaths: [],
516
+ });
517
+ await registry.setOnline(descriptor.hostId, 'ns', 'conn-1');
518
+ await registry.setOffline(descriptor.hostId, 'ns', 'conn-1');
519
+
520
+ // Wait for grace to expire
521
+ await new Promise(r => { setTimeout(r, 100); });
522
+
523
+ const host = cacheMap.get(`host:registry:ns:${descriptor.hostId}`) as HostDescriptor;
524
+ expect(host.status).toBe('offline');
525
+ });
526
+
527
+ it('stays online when other connections remain', async () => {
528
+ const { cache, store: cacheMap } = makeCache();
529
+ const registry = new HostRegistry(cache, undefined, { reconnectGraceMs: 100 });
530
+
531
+ const { descriptor } = await registry.register({
532
+ name: 'multi-conn', namespaceId: 'ns', capabilities: [], workspacePaths: [],
533
+ });
534
+ await registry.setOnline(descriptor.hostId, 'ns', 'conn-1');
535
+ // setOnline replaces connections, so simulate by directly setting
536
+ const key = `host:registry:ns:${descriptor.hostId}`;
537
+ const h = cacheMap.get(key) as HostDescriptor;
538
+ cacheMap.set(key, { ...h, connections: ['conn-1', 'conn-2'] });
539
+
540
+ await registry.setOffline(descriptor.hostId, 'ns', 'conn-1');
541
+
542
+ const host = cacheMap.get(key) as HostDescriptor;
543
+ expect(host.status).toBe('online');
544
+ expect(host.connections).toEqual(['conn-2']);
545
+ });
546
+ });