@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,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
|
+
}
|