@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,305 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { ICache } from '@kb-labs/core-platform';
3
+ import type { HostDescriptor, HostRegistration, IHostStore } from '@kb-labs/gateway-contracts';
4
+
5
+ export interface HostRegisterResult {
6
+ descriptor: HostDescriptor;
7
+ machineToken: string;
8
+ }
9
+
10
+ /**
11
+ * Host Registry — coordinates cache (hot) and store (cold) layers.
12
+ *
13
+ * - Cache: online/offline status, connections, heartbeat (transient)
14
+ * - Store: host descriptors, tokens (durable, survives restarts)
15
+ *
16
+ * Write path: store.save() + cache.set()
17
+ * Read path: cache.get() ?? store.get() → cache warm
18
+ */
19
+ const DEFAULT_RECONNECT_GRACE_MS = 10_000;
20
+
21
+ export class HostRegistry {
22
+ private readonly graceTimers = new Map<string, ReturnType<typeof setTimeout>>();
23
+ private readonly reconnectGraceMs: number;
24
+
25
+ constructor(
26
+ private readonly cache: ICache,
27
+ private readonly store?: IHostStore,
28
+ options?: { reconnectGraceMs?: number },
29
+ ) {
30
+ this.reconnectGraceMs = options?.reconnectGraceMs ?? DEFAULT_RECONNECT_GRACE_MS;
31
+ }
32
+
33
+ /**
34
+ * Restore hosts from persistent store into cache on startup.
35
+ * All restored hosts start as offline — live status comes from WS connections.
36
+ *
37
+ * Also resets any stale online/reconnecting hosts in cache to offline,
38
+ * since no WebSocket connections survive a Gateway restart.
39
+ */
40
+ async restore(): Promise<number> {
41
+ // 1. Reset stale hosts in cache (covers Redis cache surviving restarts)
42
+ await this.resetStaleHosts();
43
+
44
+ // 2. Restore from store if available
45
+ if (!this.store) {return 0;}
46
+ const hosts = await this.store.listAll();
47
+ for (const host of hosts) {
48
+ const offline = { ...host, status: 'offline' as const, connections: [] as string[] };
49
+ const cacheKey = this.hostKey(host.namespaceId, host.hostId);
50
+ await this.cache.set(cacheKey, offline);
51
+ await this.store.save(offline);
52
+ await this.addToIndex(host.namespaceId, host.hostId);
53
+ }
54
+ return hosts.length;
55
+ }
56
+
57
+ /**
58
+ * Reset all hosts in cache to offline.
59
+ * Called on startup — no WS connections exist yet, so nothing should be online.
60
+ * Uses namespace index maintained in cache to discover all namespaces.
61
+ */
62
+ private async resetStaleHosts(): Promise<void> {
63
+ const namespaces = await this.cache.get<string[]>('host:namespaces') ?? ['default'];
64
+ for (const ns of namespaces) {
65
+ const hostIds = await this.cache.get<string[]>(`host:index:${ns}`) ?? [];
66
+ for (const hostId of hostIds) {
67
+ const host = await this.cache.get<HostDescriptor>(this.hostKey(ns, hostId));
68
+ if (host && (host.status === 'online' || host.status === 'reconnecting')) {
69
+ await this.cache.set(this.hostKey(ns, hostId), {
70
+ ...host, status: 'offline', connections: [],
71
+ });
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ async register(reg: HostRegistration): Promise<HostRegisterResult> {
78
+ const hostId = `host_${randomUUID().replace(/-/g, '').slice(0, 24)}`;
79
+ const machineToken = randomUUID();
80
+ const now = Date.now();
81
+
82
+ const descriptor: HostDescriptor = {
83
+ hostId,
84
+ name: reg.name,
85
+ namespaceId: reg.namespaceId,
86
+ capabilities: reg.capabilities,
87
+ status: 'offline',
88
+ lastSeen: now,
89
+ connections: [],
90
+ hostType: reg.hostType,
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ };
94
+
95
+ // Persist to store (durable)
96
+ if (this.store) {
97
+ await this.store.save(descriptor);
98
+ await this.store.saveToken(machineToken, hostId, reg.namespaceId);
99
+ }
100
+
101
+ // Write to cache (hot)
102
+ await this.cache.set(this.hostKey(reg.namespaceId, hostId), descriptor);
103
+ await this.cache.set(this.tokenKey(machineToken), { hostId, namespaceId: reg.namespaceId });
104
+ await this.addToIndex(reg.namespaceId, hostId);
105
+
106
+ return { descriptor, machineToken };
107
+ }
108
+
109
+ async setOnline(hostId: string, namespaceId: string, connectionId: string): Promise<void> {
110
+ const host = await this.getFromCache(hostId, namespaceId);
111
+ if (!host) {return;}
112
+
113
+ // Cancel grace timer if reconnecting
114
+ const graceKey = `${namespaceId}:${hostId}`;
115
+ const graceTimer = this.graceTimers.get(graceKey);
116
+ if (graceTimer) {
117
+ clearTimeout(graceTimer);
118
+ this.graceTimers.delete(graceKey);
119
+ }
120
+
121
+ const updated = {
122
+ ...host,
123
+ status: 'online' as const,
124
+ lastSeen: Date.now(),
125
+ connections: [connectionId],
126
+ };
127
+ await this.cache.set(this.hostKey(namespaceId, hostId), updated);
128
+ if (this.store) {await this.store.save(updated);}
129
+ }
130
+
131
+ async setOffline(hostId: string, namespaceId: string, connectionId: string): Promise<void> {
132
+ const host = await this.getFromCache(hostId, namespaceId);
133
+ if (!host) {return;}
134
+ const connections = host.connections.filter((c) => c !== connectionId);
135
+
136
+ if (connections.length > 0) {
137
+ // Other connections still active — stay online
138
+ await this.cache.set(this.hostKey(namespaceId, hostId), {
139
+ ...host, status: 'online', lastSeen: Date.now(), connections,
140
+ });
141
+ return;
142
+ }
143
+
144
+ // Last connection gone — enter grace period (reconnecting)
145
+ const reconnecting = {
146
+ ...host, status: 'reconnecting' as const, lastSeen: Date.now(), connections: [] as string[],
147
+ };
148
+ await this.cache.set(this.hostKey(namespaceId, hostId), reconnecting);
149
+ if (this.store) {await this.store.save(reconnecting);}
150
+
151
+ // Cancel any existing grace timer for this host
152
+ const graceKey = `${namespaceId}:${hostId}`;
153
+ const existing = this.graceTimers.get(graceKey);
154
+ if (existing) {clearTimeout(existing);}
155
+
156
+ // After grace period, mark truly offline
157
+ this.graceTimers.set(graceKey, setTimeout(async () => {
158
+ this.graceTimers.delete(graceKey);
159
+ const current = await this.getFromCache(hostId, namespaceId);
160
+ if (current?.status === 'reconnecting') {
161
+ const offline = { ...current, status: 'offline' as const, lastSeen: Date.now() };
162
+ await this.cache.set(this.hostKey(namespaceId, hostId), offline);
163
+ if (this.store) {await this.store.save(offline);}
164
+ }
165
+ }, this.reconnectGraceMs));
166
+ }
167
+
168
+ async heartbeat(hostId: string, namespaceId: string): Promise<void> {
169
+ const host = await this.getFromCache(hostId, namespaceId);
170
+ if (!host) {return;}
171
+ await this.cache.set(this.hostKey(namespaceId, hostId), { ...host, lastSeen: Date.now() });
172
+ }
173
+
174
+ async get(hostId: string, namespaceId: string): Promise<HostDescriptor | null> {
175
+ // Try cache first (hot)
176
+ const cached = await this.cache.get<HostDescriptor>(this.hostKey(namespaceId, hostId));
177
+ if (cached) {return cached;}
178
+
179
+ // Fall through to store (cold)
180
+ if (!this.store) {return null;}
181
+ const stored = await this.store.get(hostId, namespaceId);
182
+ if (!stored) {return null;}
183
+
184
+ // Warm cache
185
+ await this.cache.set(this.hostKey(namespaceId, hostId), { ...stored, status: 'offline', connections: [] });
186
+ await this.addToIndex(namespaceId, hostId);
187
+ return { ...stored, status: 'offline', connections: [] };
188
+ }
189
+
190
+ async resolveToken(token: string): Promise<{ hostId: string; namespaceId: string } | null> {
191
+ // Try cache first
192
+ const cached = await this.cache.get<{ hostId: string; namespaceId: string }>(this.tokenKey(token));
193
+ if (cached) {return cached;}
194
+
195
+ // Fall through to store
196
+ if (!this.store) {return null;}
197
+ const stored = await this.store.resolveToken(token);
198
+ if (!stored) {return null;}
199
+
200
+ // Warm cache
201
+ await this.cache.set(this.tokenKey(token), stored);
202
+ return stored;
203
+ }
204
+
205
+ async list(namespaceId: string): Promise<HostDescriptor[]> {
206
+ // Use store as authoritative source if available
207
+ if (this.store) {
208
+ const persisted = await this.store.list(namespaceId);
209
+ // Enrich with live status from cache
210
+ return Promise.all(
211
+ persisted.map(async (host) => {
212
+ const cached = await this.cache.get<HostDescriptor>(this.hostKey(namespaceId, host.hostId));
213
+ return cached ?? { ...host, status: 'offline' as const, connections: [] };
214
+ }),
215
+ );
216
+ }
217
+
218
+ // Fallback: cache-only (no store)
219
+ const indexKey = `host:index:${namespaceId}`;
220
+ const hostIds = (await this.cache.get<string[]>(indexKey)) ?? [];
221
+ const results = await Promise.all(
222
+ hostIds.map((id) => this.cache.get<HostDescriptor>(this.hostKey(namespaceId, id))),
223
+ );
224
+ return results.filter((h): h is HostDescriptor => h !== null);
225
+ }
226
+
227
+ async deregister(hostId: string, namespaceId: string): Promise<boolean> {
228
+ // Remove from store
229
+ const deleted = this.store ? await this.store.delete(hostId, namespaceId) : false;
230
+
231
+ // Remove from cache
232
+ await this.cache.delete(this.hostKey(namespaceId, hostId));
233
+ await this.removeFromIndex(namespaceId, hostId);
234
+
235
+ return deleted;
236
+ }
237
+
238
+ async ensureRegistered(
239
+ hostId: string,
240
+ namespaceId: string,
241
+ name: string,
242
+ capabilities: HostDescriptor['capabilities'] = [],
243
+ ): Promise<void> {
244
+ const existing = await this.get(hostId, namespaceId);
245
+ if (existing) {
246
+ if (capabilities.length > 0 && JSON.stringify(existing.capabilities) !== JSON.stringify(capabilities)) {
247
+ const updated = { ...existing, capabilities, updatedAt: Date.now() };
248
+ if (this.store) {await this.store.save(updated);}
249
+ await this.cache.set(this.hostKey(namespaceId, hostId), updated);
250
+ }
251
+ return;
252
+ }
253
+
254
+ const now = Date.now();
255
+ const descriptor: HostDescriptor = {
256
+ hostId,
257
+ name,
258
+ namespaceId,
259
+ capabilities,
260
+ status: 'offline',
261
+ lastSeen: now,
262
+ connections: [],
263
+ createdAt: now,
264
+ updatedAt: now,
265
+ };
266
+
267
+ if (this.store) {await this.store.save(descriptor);}
268
+ await this.cache.set(this.hostKey(namespaceId, hostId), descriptor);
269
+ await this.addToIndex(namespaceId, hostId);
270
+ }
271
+
272
+ // ── Private helpers ──────────────────────────────────────────────
273
+
274
+ private hostKey(namespaceId: string, hostId: string): string {
275
+ return `host:registry:${namespaceId}:${hostId}`;
276
+ }
277
+
278
+ private tokenKey(token: string): string {
279
+ return `host:token:${token}`;
280
+ }
281
+
282
+ private async getFromCache(hostId: string, namespaceId: string): Promise<HostDescriptor | null> {
283
+ return this.cache.get<HostDescriptor>(this.hostKey(namespaceId, hostId));
284
+ }
285
+
286
+ private async addToIndex(namespaceId: string, hostId: string): Promise<void> {
287
+ const indexKey = `host:index:${namespaceId}`;
288
+ const hostIds = (await this.cache.get<string[]>(indexKey)) ?? [];
289
+ if (!hostIds.includes(hostId)) {
290
+ await this.cache.set(indexKey, [...hostIds, hostId]);
291
+ }
292
+ // Track namespace for resetStaleHosts()
293
+ const nsKey = 'host:namespaces';
294
+ const namespaces = (await this.cache.get<string[]>(nsKey)) ?? [];
295
+ if (!namespaces.includes(namespaceId)) {
296
+ await this.cache.set(nsKey, [...namespaces, namespaceId]);
297
+ }
298
+ }
299
+
300
+ private async removeFromIndex(namespaceId: string, hostId: string): Promise<void> {
301
+ const indexKey = `host:index:${namespaceId}`;
302
+ const hostIds = (await this.cache.get<string[]>(indexKey)) ?? [];
303
+ await this.cache.set(indexKey, hostIds.filter((id) => id !== hostId));
304
+ }
305
+ }