@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.
- package/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/package.json +49 -0
- package/src/__tests__/auth-routes.test.ts +279 -0
- package/src/__tests__/execute-routes.test.ts +408 -0
- package/src/__tests__/execution-registry.test.ts +218 -0
- package/src/__tests__/health.test.ts +215 -0
- package/src/__tests__/live-gateway.e2e.test.ts +648 -0
- package/src/__tests__/llm-gateway.test.ts +361 -0
- package/src/__tests__/observability-collector.test.ts +59 -0
- package/src/__tests__/platform-api.test.ts +317 -0
- package/src/__tests__/registry.test.ts +546 -0
- package/src/__tests__/retry-executor.test.ts +244 -0
- package/src/__tests__/server.integration.test.ts +417 -0
- package/src/__tests__/subscription-registry.test.ts +308 -0
- package/src/__tests__/telemetry-ingest.test.ts +309 -0
- package/src/__tests__/tokens.test.ts +83 -0
- package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
- package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
- package/src/auth/middleware.ts +50 -0
- package/src/auth/routes.ts +57 -0
- package/src/auth/tokens.ts +41 -0
- package/src/bootstrap.ts +98 -0
- package/src/clients/subscription-registry.ts +137 -0
- package/src/clients/ws-handler.ts +196 -0
- package/src/config.ts +20 -0
- package/src/docs/routes.ts +70 -0
- package/src/execute/errors.ts +21 -0
- package/src/execute/execution-registry.ts +84 -0
- package/src/execute/retry-executor.ts +159 -0
- package/src/execute/routes.ts +239 -0
- package/src/hosts/dispatcher.ts +2 -0
- package/src/hosts/registry.ts +305 -0
- package/src/hosts/ws-handler.ts +445 -0
- package/src/index.ts +7 -0
- package/src/llm/routes.ts +343 -0
- package/src/manifest.ts +21 -0
- package/src/observability/collector.ts +346 -0
- package/src/platform/routes.ts +195 -0
- package/src/server.ts +447 -0
- package/src/telemetry/routes.ts +89 -0
- package/src/ws/gateway-ws.ts +73 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +8 -0
- 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
|
+
});
|