@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,21 @@
1
+ /**
2
+ * @module gateway-app/execute/errors
3
+ *
4
+ * Shared error types for execution pipeline.
5
+ */
6
+
7
+ import type { CancellationReason } from '@kb-labs/core-contracts';
8
+
9
+ /**
10
+ * Thrown when an execution is cancelled (CC2).
11
+ * Caught by routes.ts to emit execution:cancelled + execution:done(130).
12
+ */
13
+ export class CancelledError extends Error {
14
+ readonly reason: CancellationReason;
15
+
16
+ constructor(reason: CancellationReason) {
17
+ super(`Execution cancelled: ${reason}`);
18
+ this.name = 'CancelledError';
19
+ this.reason = reason;
20
+ }
21
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @module gateway-app/execute/execution-registry
3
+ *
4
+ * Tracks active executions with AbortController for cancellation.
5
+ *
6
+ * Lifecycle:
7
+ * 1. POST /execute → registry.register(executionId, ...) → AbortSignal
8
+ * 2. POST /execute/:id/cancel → registry.cancel(executionId, reason) → abort()
9
+ * 3. Execution completes or aborted → registry.remove(executionId)
10
+ *
11
+ * Also supports host disconnect: cancelByHost() aborts all executions
12
+ * dispatched to a host that went offline.
13
+ */
14
+
15
+ import type { CancellationReason } from '@kb-labs/core-contracts';
16
+
17
+ export interface ActiveExecution {
18
+ executionId: string;
19
+ requestId: string;
20
+ namespaceId: string;
21
+ hostId: string;
22
+ pluginId: string;
23
+ handlerRef: string;
24
+ controller: AbortController;
25
+ startedAt: number;
26
+ cancelledReason?: CancellationReason;
27
+ }
28
+
29
+ export class ExecutionRegistry {
30
+ private executions = new Map<string, ActiveExecution>();
31
+
32
+ /** Register a new execution. Returns AbortSignal to wire into the dispatch. */
33
+ register(entry: Omit<ActiveExecution, 'controller' | 'startedAt'>): AbortSignal {
34
+ const controller = new AbortController();
35
+ this.executions.set(entry.executionId, {
36
+ ...entry,
37
+ controller,
38
+ startedAt: Date.now(),
39
+ });
40
+ return controller.signal;
41
+ }
42
+
43
+ /** Cancel an execution by ID. Returns true if found and aborted. */
44
+ cancel(executionId: string, reason: CancellationReason): boolean {
45
+ const entry = this.executions.get(executionId);
46
+ if (!entry) { return false; }
47
+ if (entry.controller.signal.aborted) { return false; }
48
+
49
+ entry.cancelledReason = reason;
50
+ entry.controller.abort(reason);
51
+ return true;
52
+ }
53
+
54
+ /** Remove a completed/cancelled execution. */
55
+ remove(executionId: string): void {
56
+ this.executions.delete(executionId);
57
+ }
58
+
59
+ /** Get an active execution. */
60
+ get(executionId: string): ActiveExecution | undefined {
61
+ return this.executions.get(executionId);
62
+ }
63
+
64
+ /** Cancel all executions dispatched to a host (on host disconnect). */
65
+ cancelByHost(hostId: string, reason: CancellationReason): string[] {
66
+ const cancelled: string[] = [];
67
+ for (const entry of this.executions.values()) {
68
+ if (entry.hostId === hostId && !entry.controller.signal.aborted) {
69
+ entry.cancelledReason = reason;
70
+ entry.controller.abort(reason);
71
+ cancelled.push(entry.executionId);
72
+ }
73
+ }
74
+ return cancelled;
75
+ }
76
+
77
+ /** Number of active executions. */
78
+ get size(): number {
79
+ return this.executions.size;
80
+ }
81
+ }
82
+
83
+ /** Singleton — shared across Gateway process. */
84
+ export const executionRegistry = new ExecutionRegistry();
@@ -0,0 +1,159 @@
1
+ /**
2
+ * @module gateway-app/execute/retry-executor
3
+ *
4
+ * Level 2 retry wrapper for execution dispatch (CC3).
5
+ *
6
+ * Wraps a dispatch function with configurable retry + exponential backoff.
7
+ * Emits execution:retry events between attempts so clients see progress.
8
+ * Respects AbortSignal — cancels immediately, no retry after abort.
9
+ *
10
+ * Each attempt is raced against the AbortSignal so that a cancelled
11
+ * execution doesn't block on a hung dispatcher call.
12
+ */
13
+
14
+ import type { ExecutionRetryConfig, CancellationReason } from '@kb-labs/core-contracts';
15
+ import type { ExecutionEventMessage } from '@kb-labs/gateway-contracts';
16
+ import { CancelledError } from './errors.js';
17
+
18
+ const DEFAULTS: Required<ExecutionRetryConfig> = {
19
+ maxAttempts: 1,
20
+ initialDelayMs: 1000,
21
+ backoffMultiplier: 2,
22
+ maxDelayMs: 30_000,
23
+ onlyRetryable: true,
24
+ };
25
+
26
+ export interface RetryContext {
27
+ executionId: string;
28
+ requestId: string;
29
+ signal: AbortSignal;
30
+ config: ExecutionRetryConfig | undefined;
31
+ /** Emit event to client stream (and WS subscribers via routes.ts). */
32
+ write: (event: ExecutionEventMessage) => void;
33
+ }
34
+
35
+ /**
36
+ * Execute dispatch function with retry + abort-race logic.
37
+ *
38
+ * - maxAttempts=1 (default) → single call, raced against signal.
39
+ * - maxAttempts>1 → retry loop with backoff, each attempt raced against signal.
40
+ */
41
+ export async function executeWithRetry<T>(
42
+ ctx: RetryContext,
43
+ dispatch: () => Promise<T>,
44
+ ): Promise<T> {
45
+ const cfg = { ...DEFAULTS, ...ctx.config };
46
+ const maxAttempts = Math.max(1, cfg.maxAttempts);
47
+
48
+ let lastError: Error | null = null;
49
+
50
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
51
+ if (ctx.signal.aborted) {
52
+ throw new CancelledError(ctx.signal.reason as CancellationReason);
53
+ }
54
+
55
+ try {
56
+ return await raceAbort(ctx.signal, dispatch());
57
+ } catch (err) {
58
+ // Cancellation is never retried
59
+ if (err instanceof CancelledError) { throw err; }
60
+
61
+ lastError = err instanceof Error ? err : new Error(String(err));
62
+
63
+ // Last attempt — propagate error
64
+ if (attempt >= maxAttempts) { break; }
65
+
66
+ // Check if error is retryable
67
+ const classified = classifyError(err);
68
+ if (cfg.onlyRetryable && !classified.retryable) { break; }
69
+
70
+ // Backoff delay
71
+ const delay = Math.min(
72
+ cfg.initialDelayMs * Math.pow(cfg.backoffMultiplier, attempt - 1),
73
+ cfg.maxDelayMs,
74
+ );
75
+
76
+ ctx.write({
77
+ type: 'execution:retry',
78
+ requestId: ctx.requestId,
79
+ executionId: ctx.executionId,
80
+ attempt,
81
+ maxAttempts,
82
+ delayMs: delay,
83
+ error: classified.message,
84
+ } satisfies ExecutionEventMessage);
85
+
86
+ await interruptibleDelay(delay, ctx.signal);
87
+ }
88
+ }
89
+
90
+ throw lastError ?? new Error('executeWithRetry: no attempts made');
91
+ }
92
+
93
+ // ── Internals ──
94
+
95
+ /**
96
+ * Race a promise against an AbortSignal.
97
+ */
98
+ function raceAbort<T>(signal: AbortSignal, promise: Promise<T>): Promise<T> {
99
+ if (signal.aborted) {
100
+ return Promise.reject(new CancelledError(signal.reason as CancellationReason));
101
+ }
102
+
103
+ return new Promise<T>((resolve, reject) => {
104
+ const onAbort = () => reject(new CancelledError(signal.reason as CancellationReason));
105
+ signal.addEventListener('abort', onAbort, { once: true });
106
+
107
+ promise.then(
108
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
109
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); },
110
+ );
111
+ });
112
+ }
113
+
114
+ /**
115
+ * Sleep interruptible by AbortSignal.
116
+ */
117
+ function interruptibleDelay(ms: number, signal: AbortSignal): Promise<void> {
118
+ if (signal.aborted) {
119
+ return Promise.reject(new CancelledError(signal.reason as CancellationReason));
120
+ }
121
+
122
+ return new Promise<void>((resolve, reject) => {
123
+ const timer = setTimeout(resolve, ms);
124
+ const onAbort = () => {
125
+ clearTimeout(timer);
126
+ reject(new CancelledError(signal.reason as CancellationReason));
127
+ };
128
+ signal.addEventListener('abort', onAbort, { once: true });
129
+ });
130
+ }
131
+
132
+ interface ClassifiedError {
133
+ code: string;
134
+ message: string;
135
+ retryable: boolean;
136
+ }
137
+
138
+ function classifyError(err: unknown): ClassifiedError {
139
+ if (!(err instanceof Error)) {
140
+ return { code: 'UNKNOWN', message: String(err), retryable: false };
141
+ }
142
+
143
+ const msg = err.message;
144
+
145
+ // Transport / network — retryable
146
+ if (msg.includes('ECONNREFUSED') || msg.includes('ECONNRESET') ||
147
+ msg.includes('ETIMEDOUT') || msg.includes('timed out') ||
148
+ msg.includes('503') || msg.includes('Service Unavailable')) {
149
+ return { code: 'TRANSPORT_ERROR', message: msg, retryable: true };
150
+ }
151
+
152
+ // Host went offline — retryable (may reconnect)
153
+ if (msg.includes('Host not connected')) {
154
+ return { code: 'HOST_UNAVAILABLE', message: msg, retryable: true };
155
+ }
156
+
157
+ // Everything else — not retryable
158
+ return { code: 'HANDLER_ERROR', message: msg, retryable: false };
159
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @module gateway-app/execute/routes
3
+ *
4
+ * POST /api/v1/execute — Execute handler via Gateway with ndjson streaming.
5
+ * POST /api/v1/execute/:executionId/cancel — Cancel an active execution.
6
+ *
7
+ * Response: Transfer-Encoding: chunked, Content-Type: application/x-ndjson
8
+ * Each line = JSON ExecutionEvent.
9
+ *
10
+ * Integrates:
11
+ * CC2 — Cancellation via ExecutionRegistry + AbortController
12
+ * CC3 — Retry via executeWithRetry (exponential backoff, retryable errors)
13
+ * CC5 — Broadcast to WS subscribers via SubscriptionRegistry
14
+ */
15
+
16
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { ExecuteRequestSchema, type ExecutionEventMessage } from '@kb-labs/gateway-contracts';
19
+ import { logDiagnosticEvent, type ILogger } from '@kb-labs/core-platform';
20
+ import type { CancellationReason } from '@kb-labs/core-contracts';
21
+ import { globalDispatcher } from '../hosts/dispatcher.js';
22
+ import { executionRegistry } from './execution-registry.js';
23
+ import { subscriptionRegistry } from '../clients/subscription-registry.js';
24
+ import { executeWithRetry } from './retry-executor.js';
25
+ import { CancelledError } from './errors.js';
26
+
27
+ export function registerExecuteRoutes(app: FastifyInstance, logger: ILogger): void {
28
+ /**
29
+ * POST /api/v1/execute
30
+ * hide: true — uses ndjson chunked streaming via reply.raw, incompatible with OpenAPI response schema
31
+ */
32
+ app.post('/api/v1/execute', { schema: { tags: ['Execute'], summary: 'Execute a plugin handler', hide: true } }, async (request: FastifyRequest, reply: FastifyReply) => {
33
+ const auth = request.authContext;
34
+ if (!auth) {
35
+ return reply.code(401).send({ error: 'Unauthorized' });
36
+ }
37
+
38
+ const parsed = ExecuteRequestSchema.safeParse(request.body);
39
+ if (!parsed.success) {
40
+ return reply.code(400).send({ error: 'Bad Request', issues: parsed.error.issues });
41
+ }
42
+
43
+ const { pluginId, handlerRef, exportName, input, timeoutMs } = parsed.data;
44
+ const executionId = randomUUID();
45
+ const requestId = randomUUID();
46
+ const startTime = Date.now();
47
+
48
+ logger.info('Execute request received', {
49
+ executionId,
50
+ pluginId,
51
+ handlerRef,
52
+ namespaceId: auth.namespaceId,
53
+ });
54
+
55
+ // Resolve host with 'execution' capability
56
+ const hostId = globalDispatcher.firstHostWithCapability(auth.namespaceId, 'execution');
57
+ if (!hostId) {
58
+ logDiagnosticEvent(logger, {
59
+ domain: 'service',
60
+ event: 'gateway.execution.dispatch',
61
+ level: 'warn',
62
+ reasonCode: 'execution_host_unavailable',
63
+ message: 'No execution host connected for namespace',
64
+ outcome: 'failed',
65
+ serviceId: 'gateway',
66
+ evidence: {
67
+ namespaceId: auth.namespaceId,
68
+ pluginId,
69
+ handlerRef,
70
+ },
71
+ });
72
+ return reply.code(503).send({
73
+ error: 'No execution host connected',
74
+ hint: 'Ensure a RuntimeServer is running and connected to Gateway',
75
+ namespaceId: auth.namespaceId,
76
+ });
77
+ }
78
+
79
+ // Register in execution registry (CC2)
80
+ const signal = executionRegistry.register({
81
+ executionId,
82
+ requestId,
83
+ namespaceId: auth.namespaceId,
84
+ hostId,
85
+ pluginId,
86
+ handlerRef,
87
+ });
88
+
89
+ // Auto-cancel on client disconnect.
90
+ // reply.raw 'close' fires when connection drops before response finishes.
91
+ // Check writableFinished to distinguish mid-stream disconnect from normal completion.
92
+ reply.raw.on('close', () => {
93
+ if (!reply.raw.writableFinished && !signal.aborted) {
94
+ executionRegistry.cancel(executionId, 'disconnect');
95
+ }
96
+ });
97
+
98
+ // Hijack response for ndjson streaming
99
+ reply.raw.writeHead(200, {
100
+ 'Content-Type': 'application/x-ndjson',
101
+ 'Transfer-Encoding': 'chunked',
102
+ 'Cache-Control': 'no-cache',
103
+ 'X-Execution-Id': executionId,
104
+ });
105
+ // Flush headers immediately so the client can see X-Execution-Id before any events
106
+ reply.raw.flushHeaders();
107
+
108
+ // Write typed event to initiator ndjson stream + broadcast to WS subscribers (CC5)
109
+ const writeEvent = (event: ExecutionEventMessage): void => {
110
+ const payload = JSON.stringify(event) + '\n';
111
+ if (!reply.raw.writableEnded) {
112
+ reply.raw.write(payload);
113
+ }
114
+ subscriptionRegistry.broadcast(executionId, event);
115
+ };
116
+
117
+ try {
118
+ // Dispatch with retry + abort-race (CC2 + CC3)
119
+ // TODO: read retry config from platform config when available
120
+ const result = await executeWithRetry(
121
+ { executionId, requestId, signal, config: undefined, write: writeEvent },
122
+ () => globalDispatcher.call(
123
+ auth.namespaceId,
124
+ hostId,
125
+ 'execution',
126
+ 'execute',
127
+ [{ pluginId, handlerRef, exportName, input, executionId, requestId, timeoutMs }],
128
+ ),
129
+ );
130
+
131
+ writeEvent({
132
+ type: 'execution:done',
133
+ requestId,
134
+ executionId,
135
+ exitCode: 0,
136
+ durationMs: Date.now() - startTime,
137
+ metadata: { result: result as Record<string, unknown> },
138
+ });
139
+ } catch (err) {
140
+ if (err instanceof CancelledError) {
141
+ writeEvent({
142
+ type: 'execution:cancelled',
143
+ requestId,
144
+ executionId,
145
+ reason: err.reason ?? 'user',
146
+ durationMs: Date.now() - startTime,
147
+ });
148
+
149
+ writeEvent({
150
+ type: 'execution:done',
151
+ requestId,
152
+ executionId,
153
+ exitCode: 130,
154
+ durationMs: Date.now() - startTime,
155
+ });
156
+ } else {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ logDiagnosticEvent(logger, {
159
+ domain: 'service',
160
+ event: 'gateway.execution.dispatch',
161
+ level: 'error',
162
+ reasonCode: 'execution_dispatch_failed',
163
+ message: 'Gateway execution dispatch failed',
164
+ outcome: 'failed',
165
+ error: err instanceof Error ? err : new Error(String(err)),
166
+ serviceId: 'gateway',
167
+ evidence: {
168
+ namespaceId: auth.namespaceId,
169
+ executionId,
170
+ requestId,
171
+ pluginId,
172
+ handlerRef,
173
+ hostId,
174
+ },
175
+ });
176
+
177
+ writeEvent({
178
+ type: 'execution:error',
179
+ requestId,
180
+ executionId,
181
+ code: 'EXECUTION_FAILED',
182
+ message,
183
+ retryable: false,
184
+ });
185
+
186
+ writeEvent({
187
+ type: 'execution:done',
188
+ requestId,
189
+ executionId,
190
+ exitCode: 1,
191
+ durationMs: Date.now() - startTime,
192
+ });
193
+ }
194
+ } finally {
195
+ executionRegistry.remove(executionId);
196
+ if (!reply.raw.writableEnded) {
197
+ reply.raw.end();
198
+ }
199
+ }
200
+ });
201
+
202
+ /**
203
+ * POST /api/v1/execute/:executionId/cancel
204
+ */
205
+ app.post('/api/v1/execute/:executionId/cancel', { schema: { tags: ['Execute'], summary: 'Cancel an active execution' } }, async (request: FastifyRequest, reply: FastifyReply) => {
206
+ const auth = request.authContext;
207
+ if (!auth) {
208
+ return reply.code(401).send({ error: 'Unauthorized' });
209
+ }
210
+
211
+ const { executionId } = request.params as { executionId: string };
212
+ const body = request.body as { reason?: string } | undefined;
213
+ const reason = (body?.reason ?? 'user') as CancellationReason;
214
+
215
+ const execution = executionRegistry.get(executionId);
216
+ if (!execution) {
217
+ return reply.code(404).send({ error: 'Execution not found or already completed' });
218
+ }
219
+
220
+ if (execution.namespaceId !== auth.namespaceId) {
221
+ return reply.code(403).send({ error: 'Forbidden — execution belongs to another namespace' });
222
+ }
223
+
224
+ const cancelled = executionRegistry.cancel(executionId, reason);
225
+
226
+ logger.info('Cancel request processed', {
227
+ executionId,
228
+ reason,
229
+ cancelled,
230
+ namespaceId: auth.namespaceId,
231
+ });
232
+
233
+ return reply.code(cancelled ? 200 : 409).send({
234
+ executionId,
235
+ status: cancelled ? 'cancelled' : 'already_cancelled',
236
+ reason,
237
+ });
238
+ });
239
+ }
@@ -0,0 +1,2 @@
1
+ // Re-export from gateway-core for backward compatibility within this app
2
+ export { HostCallDispatcher, globalDispatcher } from '@kb-labs/gateway-core';