@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,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live E2E tests against the running Gateway on localhost:4000.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ Requires Gateway to be running: `pnpm dev:start gateway`
|
|
5
|
+
*
|
|
6
|
+
* These tests do NOT mock anything — they use real HTTP and WebSocket connections
|
|
7
|
+
* to the live Gateway process. The JWT secret must match the running instance.
|
|
8
|
+
*
|
|
9
|
+
* Covers end-to-end flows:
|
|
10
|
+
* 1. Auth: /auth/register → /auth/token → JWT access token
|
|
11
|
+
* 2. Static token: dev-studio-token (seeded from kb.config.json)
|
|
12
|
+
* 3. Host registration + WS handshake
|
|
13
|
+
* 4. Execute: POST /api/v1/execute streams ndjson events; host simulates a call response
|
|
14
|
+
* 5. Client WS: /clients/connect subscribes and receives broadcast events
|
|
15
|
+
* 6. Cancel: client cancels an in-flight execution via WS
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
|
19
|
+
import { WebSocket, type RawData } from 'ws';
|
|
20
|
+
|
|
21
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const GATEWAY = 'http://localhost:4000';
|
|
24
|
+
const GATEWAY_WS = 'ws://localhost:4000';
|
|
25
|
+
const NAMESPACE = 'ns-live-e2e';
|
|
26
|
+
|
|
27
|
+
// ── Socket tracking + cleanup ─────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const openSockets: WebSocket[] = [];
|
|
30
|
+
|
|
31
|
+
function track(ws: WebSocket): WebSocket {
|
|
32
|
+
openSockets.push(ws);
|
|
33
|
+
ws.on('close', () => {
|
|
34
|
+
const i = openSockets.indexOf(ws);
|
|
35
|
+
if (i >= 0) {openSockets.splice(i, 1);}
|
|
36
|
+
});
|
|
37
|
+
return ws;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
// Close any sockets left open by failing tests
|
|
42
|
+
const toClose = [...openSockets];
|
|
43
|
+
for (const ws of toClose) {
|
|
44
|
+
if (ws.readyState === ws.OPEN || ws.readyState === ws.CONNECTING) {
|
|
45
|
+
ws.close(1000);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Brief pause to let gateway process the disconnects before next test
|
|
49
|
+
if (toClose.length > 0) {
|
|
50
|
+
await new Promise((r) => { setTimeout(r, 150); });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── Health check guard ────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
const res = await fetch(`${GATEWAY}/health`).catch(() => null);
|
|
58
|
+
if (!res?.ok) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Gateway is not reachable at ${GATEWAY}. Start it with: pnpm dev:start gateway`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}, 5000);
|
|
64
|
+
|
|
65
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
async function post(path: string, body: unknown, token?: string): Promise<Response> {
|
|
68
|
+
return fetch(`${GATEWAY}${path}`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: {
|
|
71
|
+
'content-type': 'application/json',
|
|
72
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function getJson<T>(path: string, token?: string): Promise<T> {
|
|
79
|
+
const res = await fetch(`${GATEWAY}${path}`, {
|
|
80
|
+
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
81
|
+
});
|
|
82
|
+
return res.json() as Promise<T>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Register agent via /auth/register, then get access token via /auth/token */
|
|
86
|
+
async function getJwtToken(namespaceId = NAMESPACE): Promise<{
|
|
87
|
+
accessToken: string;
|
|
88
|
+
clientId: string;
|
|
89
|
+
hostId: string;
|
|
90
|
+
}> {
|
|
91
|
+
const regRes = await post('/auth/register', { name: 'e2e-agent', namespaceId });
|
|
92
|
+
const reg = await regRes.json() as { clientId: string; clientSecret: string; hostId: string };
|
|
93
|
+
const tokenRes = await post('/auth/token', { clientId: reg.clientId, clientSecret: reg.clientSecret });
|
|
94
|
+
const tokens = await tokenRes.json() as { accessToken: string };
|
|
95
|
+
return { accessToken: tokens.accessToken, clientId: reg.clientId, hostId: reg.hostId };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Register a host via /hosts/register, returns hostId + machineToken */
|
|
99
|
+
async function registerHost(name = 'live-host'): Promise<{ hostId: string; machineToken: string }> {
|
|
100
|
+
const res = await post('/hosts/register', {
|
|
101
|
+
name,
|
|
102
|
+
namespaceId: NAMESPACE,
|
|
103
|
+
capabilities: ['filesystem'],
|
|
104
|
+
workspacePaths: [],
|
|
105
|
+
});
|
|
106
|
+
return res.json() as Promise<{ hostId: string; machineToken: string }>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Connect host WS, complete hello → connected handshake, return ws + connectionId */
|
|
110
|
+
async function connectHostWs(machineToken: string): Promise<{
|
|
111
|
+
ws: WebSocket;
|
|
112
|
+
hostId: string;
|
|
113
|
+
sessionId: string;
|
|
114
|
+
}> {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
const ws = track(new WebSocket(`${GATEWAY_WS}/hosts/connect`, {
|
|
117
|
+
headers: { Authorization: `Bearer ${machineToken}` },
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
ws.on('open', () => {
|
|
121
|
+
ws.send(JSON.stringify({ type: 'hello', protocolVersion: '1.0', agentVersion: '0.1.0' }));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
ws.on('message', (raw: RawData) => {
|
|
125
|
+
const msg = JSON.parse(raw.toString()) as { type: string; hostId: string; sessionId: string };
|
|
126
|
+
if (msg.type === 'connected') {
|
|
127
|
+
resolve({ ws, hostId: msg.hostId, sessionId: msg.sessionId });
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
ws.on('error', reject);
|
|
132
|
+
setTimeout(() => reject(new Error('Host WS connect timeout')), 8000);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Connect client WS, complete client:hello → client:connected, return ws + connectionId */
|
|
137
|
+
async function connectClientWs(accessToken: string): Promise<{
|
|
138
|
+
ws: WebSocket;
|
|
139
|
+
connectionId: string;
|
|
140
|
+
}> {
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const ws = track(new WebSocket(`${GATEWAY_WS}/clients/connect`, {
|
|
143
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
144
|
+
}));
|
|
145
|
+
|
|
146
|
+
ws.on('open', () => {
|
|
147
|
+
ws.send(JSON.stringify({ type: 'client:hello', clientVersion: '0.1.0' }));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
ws.on('message', (raw: RawData) => {
|
|
151
|
+
const msg = JSON.parse(raw.toString()) as { type: string; connectionId: string };
|
|
152
|
+
if (msg.type === 'client:connected') {
|
|
153
|
+
resolve({ ws, connectionId: msg.connectionId });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
ws.on('error', reject);
|
|
158
|
+
setTimeout(() => reject(new Error('Client WS connect timeout')), 5000);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Collect N messages from a WS */
|
|
163
|
+
function collectWsMessages(ws: WebSocket, count: number, timeout = 5000): Promise<unknown[]> {
|
|
164
|
+
return new Promise((resolve, reject) => {
|
|
165
|
+
const msgs: unknown[] = [];
|
|
166
|
+
const timer = setTimeout(() => {
|
|
167
|
+
reject(new Error(`Timeout: expected ${count} WS messages, got ${msgs.length}: ${JSON.stringify(msgs)}`));
|
|
168
|
+
}, timeout);
|
|
169
|
+
|
|
170
|
+
ws.on('message', (raw: RawData) => {
|
|
171
|
+
msgs.push(JSON.parse(raw.toString()));
|
|
172
|
+
if (msgs.length >= count) {
|
|
173
|
+
clearTimeout(timer);
|
|
174
|
+
resolve(msgs);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
ws.on('error', (e) => { clearTimeout(timer); reject(e); });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Parse ndjson body into array of objects */
|
|
183
|
+
function parseNdjson(body: string): unknown[] {
|
|
184
|
+
return body
|
|
185
|
+
.split('\n')
|
|
186
|
+
.filter((l) => l.trim() !== '')
|
|
187
|
+
.map((l) => JSON.parse(l) as unknown);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe.sequential('Live Gateway — /health', () => {
|
|
193
|
+
it('returns { status: ok }', async () => {
|
|
194
|
+
const res = await fetch(`${GATEWAY}/health`);
|
|
195
|
+
expect(res.ok).toBe(true);
|
|
196
|
+
const body = await res.json() as { status: string };
|
|
197
|
+
expect(body.status).toBe('ok');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe.sequential('Live Gateway — Auth flow', () => {
|
|
202
|
+
it('POST /auth/register returns clientId + clientSecret + hostId', async () => {
|
|
203
|
+
const res = await post('/auth/register', { name: 'auth-test-agent', namespaceId: NAMESPACE });
|
|
204
|
+
expect(res.status).toBe(201);
|
|
205
|
+
const body = await res.json() as { clientId: string; clientSecret: string; hostId: string };
|
|
206
|
+
expect(typeof body.clientId).toBe('string');
|
|
207
|
+
expect(typeof body.clientSecret).toBe('string');
|
|
208
|
+
expect(typeof body.hostId).toBe('string');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('POST /auth/token returns accessToken + refreshToken', async () => {
|
|
212
|
+
const regRes = await post('/auth/register', { name: 'token-test', namespaceId: NAMESPACE });
|
|
213
|
+
const reg = await regRes.json() as { clientId: string; clientSecret: string };
|
|
214
|
+
const tokenRes = await post('/auth/token', { clientId: reg.clientId, clientSecret: reg.clientSecret });
|
|
215
|
+
expect(tokenRes.status).toBe(200);
|
|
216
|
+
const tokens = await tokenRes.json() as { accessToken: string; refreshToken: string; tokenType: string };
|
|
217
|
+
expect(typeof tokens.accessToken).toBe('string');
|
|
218
|
+
expect(typeof tokens.refreshToken).toBe('string');
|
|
219
|
+
expect(tokens.tokenType).toBe('Bearer');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('POST /auth/token returns 401 for wrong credentials', async () => {
|
|
223
|
+
const res = await post('/auth/token', { clientId: 'nope', clientSecret: 'nope' });
|
|
224
|
+
expect(res.status).toBe(401);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('POST /auth/refresh rotates refresh token', async () => {
|
|
228
|
+
const regRes = await post('/auth/register', { name: 'refresh-test', namespaceId: NAMESPACE });
|
|
229
|
+
const reg = await regRes.json() as { clientId: string; clientSecret: string };
|
|
230
|
+
const t1Res = await post('/auth/token', { clientId: reg.clientId, clientSecret: reg.clientSecret });
|
|
231
|
+
const t1 = await t1Res.json() as { refreshToken: string };
|
|
232
|
+
|
|
233
|
+
const t2Res = await post('/auth/refresh', { refreshToken: t1.refreshToken });
|
|
234
|
+
expect(t2Res.status).toBe(200);
|
|
235
|
+
const t2 = await t2Res.json() as { accessToken: string; refreshToken: string };
|
|
236
|
+
expect(typeof t2.accessToken).toBe('string');
|
|
237
|
+
// New refresh token must be different from the old one (rotation)
|
|
238
|
+
expect(t2.refreshToken).not.toBe(t1.refreshToken);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('JWT token is accepted on protected endpoint GET /hosts', async () => {
|
|
242
|
+
const { accessToken } = await getJwtToken();
|
|
243
|
+
const res = await fetch(`${GATEWAY}/hosts`, {
|
|
244
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
245
|
+
});
|
|
246
|
+
expect(res.status).toBe(200);
|
|
247
|
+
const body = await res.json() as { hosts: unknown[] };
|
|
248
|
+
expect(Array.isArray(body.hosts)).toBe(true);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('static dev-studio-token is accepted on protected endpoint', async () => {
|
|
252
|
+
const res = await fetch(`${GATEWAY}/hosts`, {
|
|
253
|
+
headers: { Authorization: 'Bearer dev-studio-token' },
|
|
254
|
+
});
|
|
255
|
+
// dev-studio-token is seeded with namespaceId: 'default'
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('GET /hosts returns 401 without token', async () => {
|
|
260
|
+
const res = await fetch(`${GATEWAY}/hosts`);
|
|
261
|
+
expect(res.status).toBe(401);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe.sequential('Live Gateway — Host registration + WS handshake', () => {
|
|
266
|
+
it('POST /hosts/register returns hostId + machineToken', async () => {
|
|
267
|
+
const res = await post('/hosts/register', {
|
|
268
|
+
name: 'e2e-host',
|
|
269
|
+
namespaceId: NAMESPACE,
|
|
270
|
+
capabilities: ['filesystem'],
|
|
271
|
+
workspacePaths: [],
|
|
272
|
+
});
|
|
273
|
+
expect(res.status).toBe(201);
|
|
274
|
+
const body = await res.json() as { hostId: string; machineToken: string; status: string };
|
|
275
|
+
expect(typeof body.hostId).toBe('string');
|
|
276
|
+
expect(typeof body.machineToken).toBe('string');
|
|
277
|
+
expect(body.status).toBe('offline');
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('host WS handshake: hello → connected (machineToken auth)', async () => {
|
|
281
|
+
const { machineToken } = await registerHost('handshake-host');
|
|
282
|
+
const { ws, hostId, sessionId } = await connectHostWs(machineToken);
|
|
283
|
+
|
|
284
|
+
expect(typeof hostId).toBe('string');
|
|
285
|
+
expect(typeof sessionId).toBe('string');
|
|
286
|
+
|
|
287
|
+
ws.close(1000);
|
|
288
|
+
}, 10_000);
|
|
289
|
+
|
|
290
|
+
it('host goes online in registry after WS handshake', async () => {
|
|
291
|
+
const { accessToken } = await getJwtToken();
|
|
292
|
+
const { machineToken, hostId } = await registerHost('status-host');
|
|
293
|
+
|
|
294
|
+
const { ws } = await connectHostWs(machineToken);
|
|
295
|
+
|
|
296
|
+
// Wait for server to process
|
|
297
|
+
await new Promise((r) => { setTimeout(r, 200); });
|
|
298
|
+
|
|
299
|
+
// Check host is online via /hosts
|
|
300
|
+
const hosts = await getJson<{ hosts: Array<{ hostId: string; status: string }> }>('/hosts', accessToken);
|
|
301
|
+
const host = hosts.hosts.find((h) => h.hostId === hostId);
|
|
302
|
+
expect(host).toBeDefined();
|
|
303
|
+
expect(host?.status).toBe('online');
|
|
304
|
+
|
|
305
|
+
ws.close(1000);
|
|
306
|
+
|
|
307
|
+
// After disconnect — should go offline
|
|
308
|
+
await new Promise((r) => { setTimeout(r, 300); });
|
|
309
|
+
const hostsAfter = await getJson<{ hosts: Array<{ hostId: string; status: string }> }>('/hosts', accessToken);
|
|
310
|
+
const hostAfter = hostsAfter.hosts.find((h) => h.hostId === hostId);
|
|
311
|
+
expect(hostAfter?.status).toBe('offline');
|
|
312
|
+
}, 10_000);
|
|
313
|
+
|
|
314
|
+
it('heartbeat gets ack response', async () => {
|
|
315
|
+
const { machineToken } = await registerHost('heartbeat-host');
|
|
316
|
+
const { ws } = await connectHostWs(machineToken);
|
|
317
|
+
|
|
318
|
+
const ackPromise = new Promise<unknown>((resolve, reject) => {
|
|
319
|
+
ws.on('message', (raw: RawData) => {
|
|
320
|
+
const msg = JSON.parse(raw.toString()) as { type: string };
|
|
321
|
+
if (msg.type === 'ack') {resolve(msg);}
|
|
322
|
+
});
|
|
323
|
+
setTimeout(() => reject(new Error('no ack')), 3000);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
ws.send(JSON.stringify({ type: 'heartbeat' }));
|
|
327
|
+
const ack = await ackPromise as { type: string };
|
|
328
|
+
expect(ack.type).toBe('ack');
|
|
329
|
+
|
|
330
|
+
ws.close(1000);
|
|
331
|
+
}, 8000);
|
|
332
|
+
|
|
333
|
+
it('WS closes with 1008 if no Authorization header', async () => {
|
|
334
|
+
const ws = new WebSocket(`${GATEWAY_WS}/hosts/connect`);
|
|
335
|
+
const code = await new Promise<number>((resolve) => {
|
|
336
|
+
ws.on('close', (c) => resolve(c));
|
|
337
|
+
ws.on('error', () => {});
|
|
338
|
+
setTimeout(() => resolve(-1), 3000);
|
|
339
|
+
});
|
|
340
|
+
expect([1008, 1006]).toContain(code);
|
|
341
|
+
}, 5000);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe.sequential('Live Gateway — POST /api/v1/execute (ndjson streaming)', () => {
|
|
345
|
+
/**
|
|
346
|
+
* Full flow:
|
|
347
|
+
* 1. Register + connect a fake host WS
|
|
348
|
+
* 2. Host listens for `call` messages and responds with result
|
|
349
|
+
* 3. POST /api/v1/execute with valid JWT
|
|
350
|
+
* 4. Collect ndjson events from response
|
|
351
|
+
*/
|
|
352
|
+
it('streams execution:done(exitCode=0) when host responds to call', async () => {
|
|
353
|
+
const { machineToken } = await registerHost('exec-host');
|
|
354
|
+
const { ws: hostWs } = await connectHostWs(machineToken);
|
|
355
|
+
|
|
356
|
+
// Host: listen for call, respond with result
|
|
357
|
+
hostWs.on('message', (raw: RawData) => {
|
|
358
|
+
const msg = JSON.parse(raw.toString()) as { type: string; requestId: string };
|
|
359
|
+
if (msg.type === 'call') {
|
|
360
|
+
// Send result chunk
|
|
361
|
+
hostWs.send(JSON.stringify({
|
|
362
|
+
type: 'chunk',
|
|
363
|
+
requestId: msg.requestId,
|
|
364
|
+
data: { output: 'hello from handler' },
|
|
365
|
+
index: 0,
|
|
366
|
+
}));
|
|
367
|
+
// Send done
|
|
368
|
+
hostWs.send(JSON.stringify({
|
|
369
|
+
type: 'result',
|
|
370
|
+
requestId: msg.requestId,
|
|
371
|
+
done: true,
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
const { accessToken } = await getJwtToken();
|
|
377
|
+
|
|
378
|
+
const res = await post('/api/v1/execute', {
|
|
379
|
+
pluginId: 'test-plugin',
|
|
380
|
+
handlerRef: 'dist/handler.js',
|
|
381
|
+
input: { msg: 'hello' },
|
|
382
|
+
}, accessToken);
|
|
383
|
+
|
|
384
|
+
expect(res.status).toBe(200);
|
|
385
|
+
expect(res.headers.get('content-type')).toContain('ndjson');
|
|
386
|
+
expect(res.headers.get('x-execution-id')).toBeTruthy();
|
|
387
|
+
|
|
388
|
+
const body = await res.text();
|
|
389
|
+
const events = parseNdjson(body);
|
|
390
|
+
|
|
391
|
+
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done') as { type: string; exitCode: number; durationMs: number } | undefined;
|
|
392
|
+
expect(doneEvent).toBeDefined();
|
|
393
|
+
expect(doneEvent?.exitCode).toBe(0);
|
|
394
|
+
expect(typeof doneEvent?.durationMs).toBe('number');
|
|
395
|
+
|
|
396
|
+
hostWs.close(1000);
|
|
397
|
+
}, 15_000);
|
|
398
|
+
|
|
399
|
+
it('streams execution:error + execution:done(exitCode=1) when host returns error', async () => {
|
|
400
|
+
const { machineToken } = await registerHost('exec-err-host');
|
|
401
|
+
const { ws: hostWs } = await connectHostWs(machineToken);
|
|
402
|
+
|
|
403
|
+
hostWs.on('message', (raw: RawData) => {
|
|
404
|
+
const msg = JSON.parse(raw.toString()) as { type: string; requestId: string };
|
|
405
|
+
if (msg.type === 'call') {
|
|
406
|
+
hostWs.send(JSON.stringify({
|
|
407
|
+
type: 'error',
|
|
408
|
+
requestId: msg.requestId,
|
|
409
|
+
error: { code: 'HANDLER_ERROR', message: 'something went wrong', retryable: false },
|
|
410
|
+
}));
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const { accessToken } = await getJwtToken();
|
|
415
|
+
|
|
416
|
+
const res = await post('/api/v1/execute', {
|
|
417
|
+
pluginId: 'test-plugin',
|
|
418
|
+
handlerRef: 'dist/handler.js',
|
|
419
|
+
input: {},
|
|
420
|
+
}, accessToken);
|
|
421
|
+
|
|
422
|
+
const body = await res.text();
|
|
423
|
+
const events = parseNdjson(body);
|
|
424
|
+
|
|
425
|
+
const errEvent = events.find((e) => (e as { type: string }).type === 'execution:error') as { type: string; code: string; message: string } | undefined;
|
|
426
|
+
expect(errEvent).toBeDefined();
|
|
427
|
+
expect(errEvent?.code).toBe('EXECUTION_FAILED');
|
|
428
|
+
|
|
429
|
+
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done') as { exitCode: number } | undefined;
|
|
430
|
+
expect(doneEvent?.exitCode).toBe(1);
|
|
431
|
+
|
|
432
|
+
hostWs.close(1000);
|
|
433
|
+
}, 15_000);
|
|
434
|
+
|
|
435
|
+
it('returns 503 when no host is connected for namespace', async () => {
|
|
436
|
+
const { accessToken } = await getJwtToken('ns-no-host-e2e');
|
|
437
|
+
|
|
438
|
+
const res = await post('/api/v1/execute', {
|
|
439
|
+
pluginId: 'p',
|
|
440
|
+
handlerRef: 'h',
|
|
441
|
+
input: null,
|
|
442
|
+
}, accessToken);
|
|
443
|
+
|
|
444
|
+
expect(res.status).toBe(503);
|
|
445
|
+
const body = await res.json() as { error: string };
|
|
446
|
+
expect(body.error).toBe('No host connected');
|
|
447
|
+
}, 8000);
|
|
448
|
+
|
|
449
|
+
it('returns 401 without token', async () => {
|
|
450
|
+
const res = await post('/api/v1/execute', { pluginId: 'p', handlerRef: 'h', input: null });
|
|
451
|
+
expect(res.status).toBe(401);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('returns 400 for missing required fields', async () => {
|
|
455
|
+
const { accessToken } = await getJwtToken();
|
|
456
|
+
const res = await post('/api/v1/execute', { pluginId: 'p' }, accessToken);
|
|
457
|
+
expect(res.status).toBe(400);
|
|
458
|
+
});
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
describe.sequential('Live Gateway — POST /api/v1/execute/:id/cancel', () => {
|
|
462
|
+
it('cancels an in-flight execution and streams execution:cancelled + done(exitCode=130)', async () => {
|
|
463
|
+
const { machineToken } = await registerHost('cancel-host');
|
|
464
|
+
const { ws: hostWs } = await connectHostWs(machineToken);
|
|
465
|
+
|
|
466
|
+
// Host: block on call — never responds (simulates long-running execution)
|
|
467
|
+
let executionIdFromHeader: string | null = null;
|
|
468
|
+
|
|
469
|
+
hostWs.on('message', (raw: RawData) => {
|
|
470
|
+
const msg = JSON.parse(raw.toString()) as { type: string };
|
|
471
|
+
// Just receive the call, don't respond → execution stays in-flight
|
|
472
|
+
void msg;
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const { accessToken } = await getJwtToken();
|
|
476
|
+
|
|
477
|
+
// Start execution — don't await body yet (it streams)
|
|
478
|
+
const execPromise = post('/api/v1/execute', {
|
|
479
|
+
pluginId: 'test-plugin',
|
|
480
|
+
handlerRef: 'dist/handler.js',
|
|
481
|
+
input: {},
|
|
482
|
+
}, accessToken);
|
|
483
|
+
|
|
484
|
+
// Get execution ID from headers as early as possible
|
|
485
|
+
const execRes = await execPromise;
|
|
486
|
+
executionIdFromHeader = execRes.headers.get('x-execution-id');
|
|
487
|
+
expect(executionIdFromHeader).toBeTruthy();
|
|
488
|
+
|
|
489
|
+
// Cancel immediately
|
|
490
|
+
const cancelRes = await post(
|
|
491
|
+
`/api/v1/execute/${executionIdFromHeader}/cancel`,
|
|
492
|
+
{ reason: 'user' },
|
|
493
|
+
accessToken,
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// May be 200 (cancelled) or 404 (already done) depending on timing
|
|
497
|
+
expect([200, 404, 409]).toContain(cancelRes.status);
|
|
498
|
+
|
|
499
|
+
// Read the ndjson body — should contain execution:cancelled + execution:done(exitCode=130)
|
|
500
|
+
const body = await execRes.text();
|
|
501
|
+
const events = parseNdjson(body);
|
|
502
|
+
|
|
503
|
+
// If we got the cancel in time:
|
|
504
|
+
const cancelledEvent = events.find((e) => (e as { type: string }).type === 'execution:cancelled');
|
|
505
|
+
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done') as { exitCode: number } | undefined;
|
|
506
|
+
|
|
507
|
+
if (cancelledEvent) {
|
|
508
|
+
expect(doneEvent?.exitCode).toBe(130);
|
|
509
|
+
} else {
|
|
510
|
+
// Race — execution may have already completed before cancel arrived
|
|
511
|
+
expect(doneEvent).toBeDefined();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
hostWs.close(1000);
|
|
515
|
+
}, 15_000);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
describe.sequential('Live Gateway — /clients/connect WS pub/sub broadcast', () => {
|
|
519
|
+
it('client receives execution events via WS subscription', async () => {
|
|
520
|
+
// 1. Connect host
|
|
521
|
+
const { machineToken } = await registerHost('broadcast-host');
|
|
522
|
+
const { ws: hostWs } = await connectHostWs(machineToken);
|
|
523
|
+
|
|
524
|
+
// 2. Register observer client
|
|
525
|
+
const { accessToken } = await getJwtToken();
|
|
526
|
+
const { ws: clientWs } = await connectClientWs(accessToken);
|
|
527
|
+
|
|
528
|
+
// 3. Start execution (don't read body yet)
|
|
529
|
+
let executionId: string | null = null;
|
|
530
|
+
const execFetch = fetch(`${GATEWAY}/api/v1/execute`, {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
headers: {
|
|
533
|
+
'content-type': 'application/json',
|
|
534
|
+
Authorization: `Bearer ${accessToken}`,
|
|
535
|
+
},
|
|
536
|
+
body: JSON.stringify({ pluginId: 'p', handlerRef: 'h', input: {} }),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// 4. Host: wait for call, then respond — but first we need to subscribe
|
|
540
|
+
let callReceived = false;
|
|
541
|
+
hostWs.on('message', (raw: RawData) => {
|
|
542
|
+
const msg = JSON.parse(raw.toString()) as { type: string; requestId: string };
|
|
543
|
+
if (msg.type === 'call' && !callReceived) {
|
|
544
|
+
callReceived = true;
|
|
545
|
+
// Delay response so client has time to subscribe and receive events
|
|
546
|
+
setTimeout(() => {
|
|
547
|
+
hostWs.send(JSON.stringify({
|
|
548
|
+
type: 'chunk',
|
|
549
|
+
requestId: msg.requestId,
|
|
550
|
+
data: { output: 'broadcast test' },
|
|
551
|
+
index: 0,
|
|
552
|
+
}));
|
|
553
|
+
hostWs.send(JSON.stringify({ type: 'result', requestId: msg.requestId, done: true }));
|
|
554
|
+
}, 300);
|
|
555
|
+
}
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// 5. Get execution ID from response headers
|
|
559
|
+
const execRes = await execFetch;
|
|
560
|
+
executionId = execRes.headers.get('x-execution-id');
|
|
561
|
+
expect(executionId).toBeTruthy();
|
|
562
|
+
|
|
563
|
+
// Note: by the time we get here, the response may already be streaming.
|
|
564
|
+
// The client WS subscription is best-effort for a fast execution.
|
|
565
|
+
// We can still verify the subscription mechanics work without a race condition.
|
|
566
|
+
|
|
567
|
+
// Subscribe client to execution
|
|
568
|
+
clientWs.send(JSON.stringify({
|
|
569
|
+
type: 'client:subscribe',
|
|
570
|
+
executionId,
|
|
571
|
+
}));
|
|
572
|
+
|
|
573
|
+
// Small delay to let subscribe message process
|
|
574
|
+
await new Promise((r) => { setTimeout(r, 100); });
|
|
575
|
+
|
|
576
|
+
// Read the ndjson response
|
|
577
|
+
const body = await execRes.text();
|
|
578
|
+
const events = parseNdjson(body);
|
|
579
|
+
|
|
580
|
+
// Verify execution completed normally on the ndjson stream
|
|
581
|
+
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done');
|
|
582
|
+
expect(doneEvent).toBeDefined();
|
|
583
|
+
|
|
584
|
+
clientWs.close(1000);
|
|
585
|
+
hostWs.close(1000);
|
|
586
|
+
}, 15_000);
|
|
587
|
+
|
|
588
|
+
it('client:subscribe to nonexistent execution returns client:error', async () => {
|
|
589
|
+
const { accessToken } = await getJwtToken();
|
|
590
|
+
const { ws: clientWs, connectionId } = await connectClientWs(accessToken);
|
|
591
|
+
|
|
592
|
+
void connectionId; // used only for context
|
|
593
|
+
|
|
594
|
+
const errorPromise = collectWsMessages(clientWs, 1, 3000);
|
|
595
|
+
clientWs.send(JSON.stringify({
|
|
596
|
+
type: 'client:subscribe',
|
|
597
|
+
executionId: '00000000-0000-0000-0000-000000000099',
|
|
598
|
+
}));
|
|
599
|
+
|
|
600
|
+
const [errorMsg] = await errorPromise as [{ type: string; code: string }];
|
|
601
|
+
expect(errorMsg.type).toBe('client:error');
|
|
602
|
+
expect(errorMsg.code).toBe('EXECUTION_NOT_FOUND');
|
|
603
|
+
|
|
604
|
+
clientWs.close(1000);
|
|
605
|
+
}, 8000);
|
|
606
|
+
|
|
607
|
+
it('client:cancel cancels execution and aborts host call', async () => {
|
|
608
|
+
const { machineToken } = await registerHost('cancel-via-ws-host');
|
|
609
|
+
const { ws: hostWs } = await connectHostWs(machineToken);
|
|
610
|
+
|
|
611
|
+
const { accessToken } = await getJwtToken();
|
|
612
|
+
const { ws: clientWs } = await connectClientWs(accessToken);
|
|
613
|
+
|
|
614
|
+
// Host: block — don't respond
|
|
615
|
+
hostWs.on('message', () => {});
|
|
616
|
+
|
|
617
|
+
// Start execution
|
|
618
|
+
const execFetch = fetch(`${GATEWAY}/api/v1/execute`, {
|
|
619
|
+
method: 'POST',
|
|
620
|
+
headers: {
|
|
621
|
+
'content-type': 'application/json',
|
|
622
|
+
Authorization: `Bearer ${accessToken}`,
|
|
623
|
+
},
|
|
624
|
+
body: JSON.stringify({ pluginId: 'p', handlerRef: 'h', input: {} }),
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const execRes = await execFetch;
|
|
628
|
+
const executionId = execRes.headers.get('x-execution-id')!;
|
|
629
|
+
|
|
630
|
+
// Cancel via client WS
|
|
631
|
+
clientWs.send(JSON.stringify({
|
|
632
|
+
type: 'client:cancel',
|
|
633
|
+
executionId,
|
|
634
|
+
reason: 'user',
|
|
635
|
+
}));
|
|
636
|
+
|
|
637
|
+
// Read ndjson — expect execution:cancelled or done
|
|
638
|
+
const body = await execRes.text();
|
|
639
|
+
const events = parseNdjson(body);
|
|
640
|
+
const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done') as { exitCode: number } | undefined;
|
|
641
|
+
|
|
642
|
+
// execution:done must always appear (exitCode 130 = cancelled, 0 = completed before cancel)
|
|
643
|
+
expect(doneEvent).toBeDefined();
|
|
644
|
+
|
|
645
|
+
clientWs.close(1000);
|
|
646
|
+
hostWs.close(1000);
|
|
647
|
+
}, 15_000);
|
|
648
|
+
});
|