@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,408 @@
1
+ /**
2
+ * Integration tests for execute routes (CC1/CC2/CC3/CC5).
3
+ *
4
+ * POST /api/v1/execute — ndjson streaming, 400/401/503, cancellation flow
5
+ * POST /api/v1/execute/:id/cancel — 200/404/403/409
6
+ *
7
+ * Uses real Fastify instance with mocked globalDispatcher.
8
+ * ndjson response is collected line-by-line.
9
+ */
10
+ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
11
+ import Fastify, { type FastifyInstance } from 'fastify';
12
+ import type { ILogger } from '@kb-labs/core-platform';
13
+
14
+ // ── Mock heavy deps before importing routes ───────────────────────────────────
15
+ // Use vi.hoisted() so variables are available when vi.mock() factory runs
16
+ // (vi.mock calls are hoisted to the top of the file, before variable declarations)
17
+
18
+ const { mockDispatcher, mockBroadcast } = vi.hoisted(() => {
19
+ const mockDispatcher = {
20
+ firstHost: vi.fn(),
21
+ firstHostWithCapability: vi.fn(),
22
+ call: vi.fn(),
23
+ };
24
+ const mockBroadcast = vi.fn();
25
+ return { mockDispatcher, mockBroadcast };
26
+ });
27
+
28
+ vi.mock('../hosts/dispatcher.js', () => ({
29
+ globalDispatcher: mockDispatcher,
30
+ HostCallDispatcher: vi.fn(),
31
+ }));
32
+
33
+ vi.mock('../clients/subscription-registry.js', () => ({
34
+ subscriptionRegistry: {
35
+ broadcast: mockBroadcast,
36
+ subscribe: vi.fn(),
37
+ unsubscribe: vi.fn(),
38
+ },
39
+ SubscriptionRegistry: vi.fn(),
40
+ }));
41
+
42
+ import { registerExecuteRoutes } from '../execute/routes.js';
43
+ import { executionRegistry } from '../execute/execution-registry.js';
44
+
45
+ // ── Helpers ───────────────────────────────────────────────────────────────────
46
+
47
+ const noopLogger: ILogger = {
48
+ info: vi.fn(),
49
+ warn: vi.fn(),
50
+ error: vi.fn(),
51
+ debug: vi.fn(),
52
+ child: vi.fn(() => noopLogger),
53
+ } as unknown as ILogger;
54
+
55
+ function makeAuthContext(namespaceId = 'ns-test') {
56
+ return {
57
+ type: 'machine' as const,
58
+ userId: 'host-001',
59
+ namespaceId,
60
+ tier: 'free' as const,
61
+ permissions: ['host:connect'],
62
+ };
63
+ }
64
+
65
+ /** Parse ndjson response body into array of parsed objects */
66
+ function parseNdjson(body: string): unknown[] {
67
+ return body
68
+ .split('\n')
69
+ .filter((line) => line.trim() !== '')
70
+ .map((line) => JSON.parse(line) as unknown);
71
+ }
72
+
73
+ let app: FastifyInstance;
74
+
75
+ beforeAll(async () => {
76
+ app = Fastify({ logger: false });
77
+
78
+ // Inject authContext via preHandler
79
+ app.addHook('preHandler', async (request) => {
80
+ (request as { authContext?: unknown }).authContext = makeAuthContext();
81
+ });
82
+
83
+ registerExecuteRoutes(app, noopLogger);
84
+ await app.ready();
85
+ });
86
+
87
+ afterAll(async () => {
88
+ await app.close();
89
+ });
90
+
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ // By default, a host exists
94
+ mockDispatcher.firstHost.mockReturnValue('host-001');
95
+ mockDispatcher.firstHostWithCapability.mockReturnValue('host-001');
96
+ // By default, dispatch resolves with a result
97
+ mockDispatcher.call.mockResolvedValue({ output: 'test-result' });
98
+ });
99
+
100
+ // ── POST /api/v1/execute — happy path ─────────────────────────────────────────
101
+
102
+ describe('POST /api/v1/execute — success', () => {
103
+ it('returns 200 with ndjson content-type', async () => {
104
+ const res = await app.inject({
105
+ method: 'POST',
106
+ url: '/api/v1/execute',
107
+ payload: { pluginId: 'my-plugin', handlerRef: 'handlers/main.js', input: { foo: 'bar' } },
108
+ });
109
+
110
+ expect(res.statusCode).toBe(200);
111
+ expect(res.headers['content-type']).toContain('application/x-ndjson');
112
+ });
113
+
114
+ it('response body contains execution:done as last event', async () => {
115
+ const res = await app.inject({
116
+ method: 'POST',
117
+ url: '/api/v1/execute',
118
+ payload: { pluginId: 'my-plugin', handlerRef: 'handlers/main.js', input: {} },
119
+ });
120
+
121
+ const events = parseNdjson(res.body);
122
+ const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done');
123
+ expect(doneEvent).toBeDefined();
124
+ expect((doneEvent as { exitCode: number }).exitCode).toBe(0);
125
+ });
126
+
127
+ it('X-Execution-Id header is present in response', async () => {
128
+ const res = await app.inject({
129
+ method: 'POST',
130
+ url: '/api/v1/execute',
131
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
132
+ });
133
+
134
+ expect(res.headers['x-execution-id']).toBeTruthy();
135
+ expect(typeof res.headers['x-execution-id']).toBe('string');
136
+ });
137
+
138
+ it('dispatches to correct namespace and host', async () => {
139
+ await app.inject({
140
+ method: 'POST',
141
+ url: '/api/v1/execute',
142
+ payload: { pluginId: 'test-plugin', handlerRef: 'handler.js', input: { x: 1 } },
143
+ });
144
+
145
+ expect(mockDispatcher.firstHostWithCapability).toHaveBeenCalledWith('ns-test', 'execution');
146
+ expect(mockDispatcher.call).toHaveBeenCalledWith(
147
+ 'ns-test',
148
+ 'host-001',
149
+ 'execution',
150
+ 'execute',
151
+ expect.arrayContaining([
152
+ expect.objectContaining({ pluginId: 'test-plugin', handlerRef: 'handler.js' }),
153
+ ]),
154
+ );
155
+ });
156
+
157
+ it('broadcasts execution:done event to WS subscribers', async () => {
158
+ await app.inject({
159
+ method: 'POST',
160
+ url: '/api/v1/execute',
161
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
162
+ });
163
+
164
+ // broadcast is called at least once (for execution:done)
165
+ const calls = mockBroadcast.mock.calls as [string, { type: string }][];
166
+ const doneCall = calls.find(([, event]) => event.type === 'execution:done');
167
+ expect(doneCall).toBeDefined();
168
+ });
169
+
170
+ it('execution is removed from registry after completion', async () => {
171
+ const before = executionRegistry.size;
172
+ await app.inject({
173
+ method: 'POST',
174
+ url: '/api/v1/execute',
175
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
176
+ });
177
+ // After response, execution should have been removed
178
+ expect(executionRegistry.size).toBe(before);
179
+ });
180
+ });
181
+
182
+ // ── POST /api/v1/execute — error cases ───────────────────────────────────────
183
+
184
+ describe('POST /api/v1/execute — error cases', () => {
185
+ it('returns 400 when pluginId is missing', async () => {
186
+ const res = await app.inject({
187
+ method: 'POST',
188
+ url: '/api/v1/execute',
189
+ payload: { handlerRef: 'h', input: {} },
190
+ });
191
+ expect(res.statusCode).toBe(400);
192
+ const body = res.json() as { error: string };
193
+ expect(body.error).toBe('Bad Request');
194
+ });
195
+
196
+ it('returns 400 when handlerRef is missing', async () => {
197
+ const res = await app.inject({
198
+ method: 'POST',
199
+ url: '/api/v1/execute',
200
+ payload: { pluginId: 'p', input: {} },
201
+ });
202
+ expect(res.statusCode).toBe(400);
203
+ });
204
+
205
+ it('returns 503 when no host is connected for namespace', async () => {
206
+ mockDispatcher.firstHostWithCapability.mockReturnValue(null);
207
+
208
+ const res = await app.inject({
209
+ method: 'POST',
210
+ url: '/api/v1/execute',
211
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
212
+ });
213
+
214
+ expect(res.statusCode).toBe(503);
215
+ const body = res.json() as { error: string; namespaceId: string };
216
+ expect(body.error).toBe('No execution host connected');
217
+ expect(body.namespaceId).toBe('ns-test');
218
+ expect(noopLogger.warn).toHaveBeenCalledWith(
219
+ 'No execution host connected for namespace',
220
+ expect.objectContaining({
221
+ diagnosticEvent: 'gateway.execution.dispatch',
222
+ reasonCode: 'execution_host_unavailable',
223
+ serviceId: 'gateway',
224
+ evidence: expect.objectContaining({
225
+ namespaceId: 'ns-test',
226
+ }),
227
+ }),
228
+ );
229
+ });
230
+
231
+ it('on dispatch failure: streams execution:error + execution:done(exitCode=1)', async () => {
232
+ mockDispatcher.call.mockRejectedValue(new Error('ECONNREFUSED connection refused'));
233
+
234
+ const res = await app.inject({
235
+ method: 'POST',
236
+ url: '/api/v1/execute',
237
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
238
+ });
239
+
240
+ expect(res.statusCode).toBe(200); // headers already sent
241
+ const events = parseNdjson(res.body);
242
+
243
+ const errorEvent = events.find((e) => (e as { type: string }).type === 'execution:error');
244
+ expect(errorEvent).toBeDefined();
245
+ expect((errorEvent as { code: string }).code).toBe('EXECUTION_FAILED');
246
+ expect(noopLogger.error).toHaveBeenCalledWith(
247
+ 'Gateway execution dispatch failed',
248
+ expect.any(Error),
249
+ expect.objectContaining({
250
+ diagnosticEvent: 'gateway.execution.dispatch',
251
+ reasonCode: 'execution_dispatch_failed',
252
+ serviceId: 'gateway',
253
+ }),
254
+ );
255
+
256
+ const doneEvent = events.find((e) => (e as { type: string }).type === 'execution:done');
257
+ expect((doneEvent as { exitCode: number }).exitCode).toBe(1);
258
+ });
259
+ });
260
+
261
+ // ── POST /api/v1/execute/:id/cancel ──────────────────────────────────────────
262
+
263
+ describe('POST /api/v1/execute/:id/cancel', () => {
264
+ it('returns 200 when execution is found and cancelled', async () => {
265
+ // Register a real execution so cancel can find it
266
+ const signal = executionRegistry.register({
267
+ executionId: 'exec-cancel-test',
268
+ requestId: 'req-1',
269
+ namespaceId: 'ns-test',
270
+ hostId: 'host-001',
271
+ pluginId: 'p',
272
+ handlerRef: 'h',
273
+ });
274
+
275
+ const res = await app.inject({
276
+ method: 'POST',
277
+ url: '/api/v1/execute/exec-cancel-test/cancel',
278
+ payload: { reason: 'user' },
279
+ });
280
+
281
+ expect(res.statusCode).toBe(200);
282
+ const body = res.json() as { executionId: string; status: string };
283
+ expect(body.status).toBe('cancelled');
284
+ expect(body.executionId).toBe('exec-cancel-test');
285
+ expect(signal.aborted).toBe(true);
286
+
287
+ executionRegistry.remove('exec-cancel-test');
288
+ });
289
+
290
+ it('returns 404 when execution does not exist', async () => {
291
+ const res = await app.inject({
292
+ method: 'POST',
293
+ url: '/api/v1/execute/nonexistent-id/cancel',
294
+ payload: {},
295
+ });
296
+
297
+ expect(res.statusCode).toBe(404);
298
+ const body = res.json() as { error: string };
299
+ expect(body.error).toContain('not found');
300
+ });
301
+
302
+ it('returns 403 when execution belongs to different namespace', async () => {
303
+ // Register an execution in a different namespace
304
+ executionRegistry.register({
305
+ executionId: 'exec-other-ns',
306
+ requestId: 'req-x',
307
+ namespaceId: 'ns-other', // different namespace
308
+ hostId: 'host-002',
309
+ pluginId: 'p',
310
+ handlerRef: 'h',
311
+ });
312
+
313
+ const res = await app.inject({
314
+ method: 'POST',
315
+ url: '/api/v1/execute/exec-other-ns/cancel',
316
+ payload: {},
317
+ });
318
+
319
+ expect(res.statusCode).toBe(403);
320
+ const body = res.json() as { error: string };
321
+ expect(body.error).toContain('Forbidden');
322
+
323
+ executionRegistry.remove('exec-other-ns');
324
+ });
325
+
326
+ it('returns 409 when execution is already cancelled', async () => {
327
+ executionRegistry.register({
328
+ executionId: 'exec-already-cancelled',
329
+ requestId: 'req-2',
330
+ namespaceId: 'ns-test',
331
+ hostId: 'host-001',
332
+ pluginId: 'p',
333
+ handlerRef: 'h',
334
+ });
335
+ // Cancel it first
336
+ executionRegistry.cancel('exec-already-cancelled', 'user');
337
+
338
+ const res = await app.inject({
339
+ method: 'POST',
340
+ url: '/api/v1/execute/exec-already-cancelled/cancel',
341
+ payload: {},
342
+ });
343
+
344
+ expect(res.statusCode).toBe(409);
345
+ const body = res.json() as { status: string };
346
+ expect(body.status).toBe('already_cancelled');
347
+
348
+ executionRegistry.remove('exec-already-cancelled');
349
+ });
350
+
351
+ it('defaults reason to "user" when not provided', async () => {
352
+ executionRegistry.register({
353
+ executionId: 'exec-default-reason',
354
+ requestId: 'req-3',
355
+ namespaceId: 'ns-test',
356
+ hostId: 'host-001',
357
+ pluginId: 'p',
358
+ handlerRef: 'h',
359
+ });
360
+
361
+ const res = await app.inject({
362
+ method: 'POST',
363
+ url: '/api/v1/execute/exec-default-reason/cancel',
364
+ payload: {},
365
+ });
366
+
367
+ expect(res.statusCode).toBe(200);
368
+ const body = res.json() as { reason: string };
369
+ expect(body.reason).toBe('user');
370
+
371
+ executionRegistry.remove('exec-default-reason');
372
+ });
373
+ });
374
+
375
+ // ── 401 without auth context ──────────────────────────────────────────────────
376
+
377
+ describe('POST /api/v1/execute — auth guard', () => {
378
+ let appNoAuth: FastifyInstance;
379
+
380
+ beforeAll(async () => {
381
+ appNoAuth = Fastify({ logger: false });
382
+ // No preHandler — authContext stays undefined
383
+ registerExecuteRoutes(appNoAuth, noopLogger);
384
+ await appNoAuth.ready();
385
+ });
386
+
387
+ afterAll(async () => {
388
+ await appNoAuth.close();
389
+ });
390
+
391
+ it('returns 401 when authContext is absent', async () => {
392
+ const res = await appNoAuth.inject({
393
+ method: 'POST',
394
+ url: '/api/v1/execute',
395
+ payload: { pluginId: 'p', handlerRef: 'h', input: null },
396
+ });
397
+ expect(res.statusCode).toBe(401);
398
+ });
399
+
400
+ it('cancel returns 401 when authContext is absent', async () => {
401
+ const res = await appNoAuth.inject({
402
+ method: 'POST',
403
+ url: '/api/v1/execute/some-id/cancel',
404
+ payload: {},
405
+ });
406
+ expect(res.statusCode).toBe(401);
407
+ });
408
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Unit tests for ExecutionRegistry (CC2 — Cancellation).
3
+ */
4
+ import { describe, it, expect, beforeEach } from 'vitest';
5
+ import { ExecutionRegistry } from '../execute/execution-registry.js';
6
+
7
+ function makeEntry(overrides: Partial<{
8
+ executionId: string;
9
+ requestId: string;
10
+ namespaceId: string;
11
+ hostId: string;
12
+ pluginId: string;
13
+ handlerRef: string;
14
+ }> = {}) {
15
+ return {
16
+ executionId: 'exec-1',
17
+ requestId: 'req-1',
18
+ namespaceId: 'ns-1',
19
+ hostId: 'host-1',
20
+ pluginId: 'plugin-a',
21
+ handlerRef: 'handlers/run.ts',
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ describe('ExecutionRegistry', () => {
27
+ let registry: ExecutionRegistry;
28
+
29
+ beforeEach(() => {
30
+ registry = new ExecutionRegistry();
31
+ });
32
+
33
+ // ── register ──────────────────────────────────────────────────────────────
34
+
35
+ describe('register()', () => {
36
+ it('returns an AbortSignal', () => {
37
+ const signal = registry.register(makeEntry());
38
+ expect(signal).toBeInstanceOf(AbortSignal);
39
+ });
40
+
41
+ it('signal is not aborted initially', () => {
42
+ const signal = registry.register(makeEntry());
43
+ expect(signal.aborted).toBe(false);
44
+ });
45
+
46
+ it('stores execution metadata', () => {
47
+ registry.register(makeEntry({ executionId: 'exec-x' }));
48
+ const entry = registry.get('exec-x');
49
+ expect(entry?.namespaceId).toBe('ns-1');
50
+ expect(entry?.hostId).toBe('host-1');
51
+ expect(entry?.pluginId).toBe('plugin-a');
52
+ expect(entry?.startedAt).toBeTypeOf('number');
53
+ });
54
+
55
+ it('increments size', () => {
56
+ registry.register(makeEntry({ executionId: 'a' }));
57
+ registry.register(makeEntry({ executionId: 'b' }));
58
+ expect(registry.size).toBe(2);
59
+ });
60
+ });
61
+
62
+ // ── cancel ────────────────────────────────────────────────────────────────
63
+
64
+ describe('cancel()', () => {
65
+ it('returns true and aborts signal', () => {
66
+ const signal = registry.register(makeEntry());
67
+ const result = registry.cancel('exec-1', 'user');
68
+ expect(result).toBe(true);
69
+ expect(signal.aborted).toBe(true);
70
+ });
71
+
72
+ it('stores cancellation reason on the entry', () => {
73
+ registry.register(makeEntry());
74
+ registry.cancel('exec-1', 'timeout');
75
+ expect(registry.get('exec-1')?.cancelledReason).toBe('timeout');
76
+ });
77
+
78
+ it('returns false for non-existent executionId', () => {
79
+ expect(registry.cancel('does-not-exist', 'user')).toBe(false);
80
+ });
81
+
82
+ it('returns false when already cancelled (idempotent)', () => {
83
+ registry.register(makeEntry());
84
+ expect(registry.cancel('exec-1', 'user')).toBe(true);
85
+ expect(registry.cancel('exec-1', 'timeout')).toBe(false);
86
+ });
87
+
88
+ it('abort signal reason matches provided reason', () => {
89
+ const signal = registry.register(makeEntry());
90
+ registry.cancel('exec-1', 'disconnect');
91
+ expect(signal.reason).toBe('disconnect');
92
+ });
93
+ });
94
+
95
+ // ── remove ────────────────────────────────────────────────────────────────
96
+
97
+ describe('remove()', () => {
98
+ it('removes execution from registry', () => {
99
+ registry.register(makeEntry());
100
+ registry.remove('exec-1');
101
+ expect(registry.get('exec-1')).toBeUndefined();
102
+ });
103
+
104
+ it('decrements size', () => {
105
+ registry.register(makeEntry({ executionId: 'a' }));
106
+ registry.register(makeEntry({ executionId: 'b' }));
107
+ registry.remove('a');
108
+ expect(registry.size).toBe(1);
109
+ });
110
+
111
+ it('is no-op for non-existent id', () => {
112
+ expect(() => registry.remove('ghost')).not.toThrow();
113
+ });
114
+ });
115
+
116
+ // ── get ───────────────────────────────────────────────────────────────────
117
+
118
+ describe('get()', () => {
119
+ it('returns undefined for unknown id', () => {
120
+ expect(registry.get('unknown')).toBeUndefined();
121
+ });
122
+
123
+ it('returns the active execution', () => {
124
+ registry.register(makeEntry({ executionId: 'exec-abc' }));
125
+ const entry = registry.get('exec-abc');
126
+ expect(entry?.executionId).toBe('exec-abc');
127
+ });
128
+ });
129
+
130
+ // ── cancelByHost ──────────────────────────────────────────────────────────
131
+
132
+ describe('cancelByHost()', () => {
133
+ it('cancels all executions for given hostId', () => {
134
+ const sig1 = registry.register(makeEntry({ executionId: 'e1', hostId: 'host-A' }));
135
+ const sig2 = registry.register(makeEntry({ executionId: 'e2', hostId: 'host-A' }));
136
+ const sig3 = registry.register(makeEntry({ executionId: 'e3', hostId: 'host-B' }));
137
+
138
+ const cancelled = registry.cancelByHost('host-A', 'disconnect');
139
+
140
+ expect(cancelled).toContain('e1');
141
+ expect(cancelled).toContain('e2');
142
+ expect(cancelled).not.toContain('e3');
143
+ expect(sig1.aborted).toBe(true);
144
+ expect(sig2.aborted).toBe(true);
145
+ expect(sig3.aborted).toBe(false);
146
+ });
147
+
148
+ it('returns empty array when no executions for host', () => {
149
+ registry.register(makeEntry({ executionId: 'e1', hostId: 'host-X' }));
150
+ const result = registry.cancelByHost('host-Z', 'disconnect');
151
+ expect(result).toEqual([]);
152
+ });
153
+
154
+ it('skips already-aborted executions', () => {
155
+ registry.register(makeEntry({ executionId: 'e1', hostId: 'host-A' }));
156
+ registry.cancel('e1', 'user'); // pre-cancel
157
+ const cancelled = registry.cancelByHost('host-A', 'disconnect');
158
+ expect(cancelled).not.toContain('e1');
159
+ });
160
+
161
+ it('returns executionIds of cancelled executions', () => {
162
+ registry.register(makeEntry({ executionId: 'alpha', hostId: 'host-1' }));
163
+ const result = registry.cancelByHost('host-1', 'disconnect');
164
+ expect(result).toEqual(['alpha']);
165
+ });
166
+ });
167
+
168
+ // ── size ──────────────────────────────────────────────────────────────────
169
+
170
+ describe('size', () => {
171
+ it('starts at 0', () => {
172
+ expect(registry.size).toBe(0);
173
+ });
174
+
175
+ it('tracks additions and removals correctly', () => {
176
+ registry.register(makeEntry({ executionId: 'a' }));
177
+ registry.register(makeEntry({ executionId: 'b' }));
178
+ registry.register(makeEntry({ executionId: 'c' }));
179
+ registry.remove('b');
180
+ expect(registry.size).toBe(2);
181
+ });
182
+ });
183
+ });
184
+
185
+ // ── CancelledError ────────────────────────────────────────────────────────────
186
+
187
+ import { CancelledError } from '../execute/errors.js';
188
+
189
+ describe('CancelledError', () => {
190
+ it('stores reason', () => {
191
+ const err = new CancelledError('timeout');
192
+ expect(err.reason).toBe('timeout');
193
+ });
194
+
195
+ it('has descriptive message', () => {
196
+ const err = new CancelledError('user');
197
+ expect(err.message).toContain('Execution cancelled');
198
+ expect(err.message).toContain('user');
199
+ });
200
+
201
+ it('has correct name', () => {
202
+ expect(new CancelledError('disconnect').name).toBe('CancelledError');
203
+ });
204
+
205
+ it('is instanceof Error', () => {
206
+ expect(new CancelledError('user')).toBeInstanceOf(Error);
207
+ });
208
+
209
+ it('is instanceof CancelledError', () => {
210
+ expect(new CancelledError('user')).toBeInstanceOf(CancelledError);
211
+ });
212
+
213
+ it('supports all cancellation reasons', () => {
214
+ for (const reason of ['user', 'timeout', 'disconnect'] as const) {
215
+ expect(() => new CancelledError(reason)).not.toThrow();
216
+ }
217
+ });
218
+ });