@kb-labs/rest-api-app 2.117.0 → 2.118.1

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/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import Fastify from 'fastify';
3
3
  import { platform } from '@kb-labs/core-runtime';
4
4
  import { EventEmitter } from 'events';
5
5
  import { performance, monitorEventLoopDelay } from 'perf_hooks';
6
- import { OperationMetricsTracker, getListenOptions, registerOpenAPI, createHttpLogger, createServiceObservabilityDescribe, createServiceObservabilityHealth } from '@kb-labs/shared-http';
6
+ import { OperationMetricsTracker, getListenOptions, registerOpenAPI, createHttpLogger, createSseStream, createServiceObservabilityDescribe, createServiceObservabilityHealth } from '@kb-labs/shared-http';
7
7
  import { Registry, Histogram, Counter, Gauge } from 'prom-client';
8
8
  import * as os from 'os';
9
9
  import { hostname } from 'os';
@@ -12,7 +12,7 @@ import * as process2 from 'process';
12
12
  import { createRegistry, mergeOpenAPISpecs } from '@kb-labs/core-registry';
13
13
  import { validateManifest } from '@kb-labs/plugin-contracts';
14
14
  import { mountRoutes } from '@kb-labs/plugin-execution/http';
15
- import { mountWebSocketChannels } from '@kb-labs/plugin-execution';
15
+ import { mountEventStreams, mountWebSocketChannels } from '@kb-labs/plugin-execution';
16
16
  import { InProcessBackend } from '@kb-labs/plugin-execution-factory';
17
17
  import { logDiagnosticEvent } from '@kb-labs/core-platform';
18
18
  import * as path from 'path';
@@ -823,8 +823,6 @@ function buildRegistrySseAuthHook(config) {
823
823
  });
824
824
  };
825
825
  }
826
-
827
- // src/routes/events.ts
828
826
  async function registerEventRoutes(server, basePath, registry, readiness, eventHub, config) {
829
827
  const endpoint = `${basePath}/events/registry`;
830
828
  const authHook = buildRegistrySseAuthHook(config);
@@ -833,25 +831,18 @@ async function registerEventRoutes(server, basePath, registry, readiness, eventH
833
831
  url: endpoint,
834
832
  onRequest: authHook ? [authHook] : void 0,
835
833
  handler: async (request, reply) => {
836
- reply.hijack();
837
834
  const origin = request.headers.origin;
838
- if (origin === "http://localhost:3000" || origin === "http://localhost:5173") {
839
- reply.raw.setHeader("Access-Control-Allow-Origin", origin);
840
- reply.raw.setHeader("Access-Control-Allow-Credentials", "true");
841
- }
842
- reply.raw.setHeader("Content-Type", "text/event-stream");
843
- reply.raw.setHeader("Cache-Control", "no-cache, no-transform");
844
- reply.raw.setHeader("Connection", "keep-alive");
845
- reply.raw.flushHeaders?.();
846
- reply.raw.write(": connected\n\n");
835
+ const stream = createSseStream(request, reply, {
836
+ logger: request.kbLogger ?? platform.logger,
837
+ serviceId: "rest",
838
+ route: endpoint,
839
+ headers: origin === "http://localhost:3000" || origin === "http://localhost:5173" ? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" } : void 0
840
+ });
847
841
  const send = (event) => {
848
- reply.raw.write(`event: ${event.type}
849
- `);
850
- reply.raw.write(`data: ${JSON.stringify(event)}
851
-
852
- `);
842
+ stream.send(event.type, event);
853
843
  };
854
844
  const unsubscribe = eventHub.subscribe(send);
845
+ stream.onCleanup(unsubscribe);
855
846
  const snapshot = registry.snapshot();
856
847
  const checksumAlgorithm = snapshot.checksumAlgorithm === "sha256" ? "sha256" : void 0;
857
848
  send({
@@ -899,20 +890,12 @@ async function registerEventRoutes(server, basePath, registry, readiness, eventH
899
890
  request.kbLogger.warn("Failed to fetch system health for SSE client", { err: error });
900
891
  }
901
892
  });
902
- await new Promise((resolve2) => {
903
- request.raw.on("close", () => {
904
- unsubscribe();
905
- reply.raw.end();
906
- resolve2();
907
- });
908
- if (request.raw.socket && !request.raw.socket.writable) {
909
- void healthPromise.then(() => {
910
- unsubscribe();
911
- reply.raw.end();
912
- resolve2();
913
- });
914
- }
915
- });
893
+ if (request.raw.socket && !request.raw.socket.writable) {
894
+ await healthPromise;
895
+ stream.close("test_complete");
896
+ return;
897
+ }
898
+ await stream.closed;
916
899
  }
917
900
  });
918
901
  }
@@ -2052,7 +2035,7 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2052
2035
  const backend = platform.executionBackend;
2053
2036
  const wsBackend = new InProcessBackend({ platform: platform });
2054
2037
  const mountableManifests = manifests.filter(
2055
- (entry) => entry.manifest.rest?.routes && entry.manifest.rest.routes.length > 0 || entry.manifest.ws?.channels && entry.manifest.ws.channels.length > 0
2038
+ (entry) => entry.manifest.rest?.routes && entry.manifest.rest.routes.length > 0 || entry.manifest.sse?.streams && entry.manifest.sse.streams.length > 0 || entry.manifest.ws?.channels && entry.manifest.ws.channels.length > 0
2056
2039
  );
2057
2040
  platform.logger.info("Plugins selected for route mounting", {
2058
2041
  mountable: mountableManifests.length,
@@ -2074,6 +2057,18 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2074
2057
  }
2075
2058
  }
2076
2059
  }
2060
+ if (entry.manifest.sse?.streams) {
2061
+ for (const stream of entry.manifest.sse.streams) {
2062
+ const handlerFile = stream.handler.split("#")[0];
2063
+ if (handlerFile) {
2064
+ const pluginDistRoot = path.join(entry.pluginRoot, "dist");
2065
+ handlerChecks.push({
2066
+ key: `${entry.manifest.id}::SSE ${stream.path}`,
2067
+ filePath: path.resolve(pluginDistRoot, handlerFile)
2068
+ });
2069
+ }
2070
+ }
2071
+ }
2077
2072
  }
2078
2073
  const handlerAccessResults = await Promise.all(
2079
2074
  handlerChecks.map(async (check) => {
@@ -2125,6 +2120,25 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2125
2120
  }
2126
2121
  }
2127
2122
  }
2123
+ if (manifest.sse?.streams) {
2124
+ for (const stream of manifest.sse.streams) {
2125
+ const handlerFile = stream.handler.split("#")[0];
2126
+ if (!handlerFile) {
2127
+ restValidationErrors.push(
2128
+ `Event stream ${stream.path}: Invalid handler reference "${stream.handler}"`
2129
+ );
2130
+ continue;
2131
+ }
2132
+ const check = handlerExistsMap.get(
2133
+ `${manifest.id}::SSE ${stream.path}`
2134
+ );
2135
+ if (check && !check.exists) {
2136
+ restValidationErrors.push(
2137
+ `Event stream ${stream.path}: Handler file not found: ${check.filePath}`
2138
+ );
2139
+ }
2140
+ }
2141
+ }
2128
2142
  if (restValidationErrors.length > 0) {
2129
2143
  const reasonCode = inferRouteValidationReasonCode(restValidationErrors);
2130
2144
  logDiagnosticEvent(platform.logger, {
@@ -2161,11 +2175,17 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2161
2175
  return match ? `${match[1]} ${match[2]}` : null;
2162
2176
  }).filter(Boolean)
2163
2177
  );
2164
- const validRoutes = manifest.rest.routes.filter((route) => {
2178
+ const validRoutes = (manifest.rest?.routes ?? []).filter((route) => {
2165
2179
  const routeKey = `${route.method} ${route.path}`;
2166
2180
  return !errorPaths.has(routeKey);
2167
2181
  });
2168
- if (validRoutes.length === 0) {
2182
+ const streamErrorPaths = new Set(
2183
+ restValidationErrors.map((error) => error.match(/Event stream\s+([^\s:]+)/)?.[1]).filter((value) => Boolean(value))
2184
+ );
2185
+ const validStreams = (manifest.sse?.streams ?? []).filter(
2186
+ (stream) => !streamErrorPaths.has(stream.path)
2187
+ );
2188
+ if (validRoutes.length === 0 && validStreams.length === 0) {
2169
2189
  restDomainOperationMetrics.recordOperation(
2170
2190
  "plugin.routes.mount",
2171
2191
  0,
@@ -2193,11 +2213,17 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2193
2213
  });
2194
2214
  continue;
2195
2215
  }
2196
- manifest.rest.routes = validRoutes;
2216
+ if (manifest.rest) {
2217
+ manifest.rest.routes = validRoutes;
2218
+ }
2219
+ if (manifest.sse) {
2220
+ manifest.sse.streams = validStreams;
2221
+ }
2197
2222
  platform.logger.info("Filtered routes, mounting valid ones", {
2198
2223
  plugin: `${manifest.id}@${manifest.version}`,
2199
- totalRoutes: manifest.rest.routes.length + restValidationErrors.length,
2224
+ totalRoutes: validRoutes.length + restValidationErrors.length,
2200
2225
  validRoutes: validRoutes.length,
2226
+ validStreams: validStreams.length,
2201
2227
  skippedRoutes: restValidationErrors.length
2202
2228
  });
2203
2229
  }
@@ -2218,13 +2244,28 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2218
2244
  } else {
2219
2245
  pluginBasePath = `${config.basePath}/plugins/${manifest.id}`;
2220
2246
  }
2247
+ let sseBasePath = pluginBasePath;
2248
+ if (manifest.sse?.basePath) {
2249
+ if (manifest.sse.basePath.startsWith("/api/")) {
2250
+ sseBasePath = manifest.sse.basePath;
2251
+ } else if (manifest.sse.basePath.startsWith("/v1/")) {
2252
+ sseBasePath = manifest.sse.basePath.replace(
2253
+ /^\/v1/,
2254
+ config.basePath
2255
+ );
2256
+ } else {
2257
+ sseBasePath = `${config.basePath}${manifest.sse.basePath}`;
2258
+ }
2259
+ }
2221
2260
  platform.logger.info("Mounting plugin routes", {
2222
2261
  plugin: `${manifest.id}@${manifest.version}`,
2223
2262
  configBasePath: config.basePath,
2224
2263
  manifestBasePath: manifest.rest?.basePath,
2225
2264
  pluginBasePath,
2265
+ sseBasePath,
2226
2266
  pluginRoot,
2227
- routes: manifest.rest?.routes?.length ?? 0
2267
+ routes: manifest.rest?.routes?.length ?? 0,
2268
+ streams: manifest.sse?.streams?.length ?? 0
2228
2269
  });
2229
2270
  await mountRoutes(server, manifest, {
2230
2271
  backend,
@@ -2233,8 +2274,28 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2233
2274
  basePath: pluginBasePath,
2234
2275
  defaultTimeoutMs: gatewayTimeoutMs
2235
2276
  });
2277
+ let streamsCount = 0;
2278
+ if (manifest.sse?.streams && manifest.sse.streams.length > 0) {
2279
+ const streamsResult = await mountEventStreams(server, manifest, {
2280
+ backend: wsBackend,
2281
+ logger: platform.logger,
2282
+ serviceId: "rest",
2283
+ pluginRoot: pluginDistRoot,
2284
+ workspaceRoot,
2285
+ basePath: sseBasePath,
2286
+ defaultTimeoutMs: manifest.sse.defaults?.timeoutMs ?? gatewayTimeoutMs,
2287
+ defaultKeepAliveMs: manifest.sse.defaults?.keepAliveMs
2288
+ });
2289
+ streamsCount = streamsResult.mounted;
2290
+ if (streamsResult.errors.length > 0) {
2291
+ platform.logger.warn("SSE stream mounting had errors", {
2292
+ plugin: `${manifest.id}@${manifest.version}`,
2293
+ errors: streamsResult.errors
2294
+ });
2295
+ }
2296
+ }
2236
2297
  const duration = performance.now() - start;
2237
- const routesCount = manifest.rest?.routes?.length ?? 0;
2298
+ const routesCount = (manifest.rest?.routes?.length ?? 0) + streamsCount;
2238
2299
  for (const route of manifest.rest?.routes ?? []) {
2239
2300
  metricsCollector.registerRouteBudget(
2240
2301
  route.method,
@@ -2261,6 +2322,7 @@ async function registerPluginRoutes(server, config, repoRoot, registry, readines
2261
2322
  plugin: `${manifest.id}@${manifest.version}`,
2262
2323
  pluginBasePath,
2263
2324
  routesCount,
2325
+ streamsCount,
2264
2326
  durationMs: Number(duration.toFixed(2))
2265
2327
  });
2266
2328
  let channelsCount = 0;
@@ -2930,38 +2992,27 @@ async function registerWorkflowRoutes(server, config) {
2930
2992
  url: `${basePath}/workflows/runs/:runId/events`,
2931
2993
  handler: async (request, reply) => {
2932
2994
  const runId = getRunId(request.params);
2933
- reply.hijack();
2934
- const raw = reply.raw;
2935
2995
  const origin = request.headers.origin;
2936
- if (typeof origin === "string" && (origin === "http://localhost:3000" || origin === "http://localhost:5173")) {
2937
- raw.setHeader("Access-Control-Allow-Origin", origin);
2938
- raw.setHeader("Access-Control-Allow-Credentials", "true");
2939
- }
2940
- raw.setHeader("Content-Type", "text/event-stream");
2941
- raw.setHeader("Cache-Control", "no-cache, no-transform");
2942
- raw.setHeader("Connection", "keep-alive");
2943
- raw.flushHeaders?.();
2944
- raw.write(": connected\n\n");
2996
+ const stream = createSseStream(request, reply, {
2997
+ logger: request.kbLogger ?? platform.logger,
2998
+ serviceId: "rest",
2999
+ route: `${basePath}/workflows/runs/:runId/events`,
3000
+ keepAliveMs: KEEP_ALIVE_MS,
3001
+ headers: typeof origin === "string" && (origin === "http://localhost:3000" || origin === "http://localhost:5173") ? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" } : void 0
3002
+ });
2945
3003
  const sentIds = /* @__PURE__ */ new Set();
2946
3004
  const sendEvent = (type, payload, timestamp) => {
2947
- if (raw.writableEnded) {
2948
- return;
2949
- }
2950
3005
  const data = { type, runId, payload, timestamp: timestamp ?? (/* @__PURE__ */ new Date()).toISOString() };
2951
3006
  const dedup = `${type}:${data.timestamp}`;
2952
3007
  if (sentIds.has(dedup)) {
2953
3008
  return;
2954
3009
  }
2955
3010
  sentIds.add(dedup);
2956
- raw.write(`event: workflow.event
2957
- `);
2958
- raw.write(`data: ${JSON.stringify(data)}
2959
-
2960
- `);
3011
+ stream.send("workflow.event", data);
2961
3012
  };
2962
3013
  const alreadyFinished = await replayHistory(runId, sendEvent);
2963
3014
  if (alreadyFinished) {
2964
- raw.end();
3015
+ stream.close("run_already_finished");
2965
3016
  return;
2966
3017
  }
2967
3018
  let idleTimer = null;
@@ -2973,12 +3024,6 @@ async function registerWorkflowRoutes(server, config) {
2973
3024
  cleanup();
2974
3025
  }, IDLE_TIMEOUT_MS);
2975
3026
  };
2976
- const keepAliveTimer = setInterval(() => {
2977
- if (raw.writableEnded) {
2978
- return;
2979
- }
2980
- raw.write(": keep-alive\n\n");
2981
- }, KEEP_ALIVE_MS);
2982
3027
  const unsubscribe = platform.eventBus.subscribe(WORKFLOW_REDIS_CHANNEL, async (rawEvent) => {
2983
3028
  const event = rawEvent;
2984
3029
  if (event.runId !== runId) {
@@ -2995,17 +3040,17 @@ async function registerWorkflowRoutes(server, config) {
2995
3040
  if (idleTimer) {
2996
3041
  clearTimeout(idleTimer);
2997
3042
  }
2998
- clearInterval(keepAliveTimer);
2999
- if (!raw.writableEnded) {
3000
- raw.write(`event: workflow.done
3001
- data: {}
3002
-
3003
- `);
3004
- raw.end();
3005
- }
3043
+ stream.send("workflow.done", {});
3044
+ stream.close("workflow_complete");
3006
3045
  };
3007
3046
  resetIdle();
3008
- request.raw.on("close", cleanup);
3047
+ stream.onCleanup(() => {
3048
+ if (idleTimer) {
3049
+ clearTimeout(idleTimer);
3050
+ }
3051
+ unsubscribe();
3052
+ });
3053
+ await stream.closed;
3009
3054
  }
3010
3055
  });
3011
3056
  server.route({
@@ -3015,41 +3060,30 @@ data: {}
3015
3060
  const runId = getRunId(request.params);
3016
3061
  const query = request.query;
3017
3062
  const idleTimeout = query.idleTimeoutMs ? parseInt(query.idleTimeoutMs, 10) : IDLE_TIMEOUT_MS;
3018
- reply.hijack();
3019
- const raw = reply.raw;
3020
3063
  const origin = request.headers.origin;
3021
- if (typeof origin === "string" && (origin === "http://localhost:3000" || origin === "http://localhost:5173")) {
3022
- raw.setHeader("Access-Control-Allow-Origin", origin);
3023
- raw.setHeader("Access-Control-Allow-Credentials", "true");
3024
- }
3025
- raw.setHeader("Content-Type", "text/event-stream");
3026
- raw.setHeader("Cache-Control", "no-cache, no-transform");
3027
- raw.setHeader("Connection", "keep-alive");
3028
- raw.flushHeaders?.();
3029
- raw.write(": connected\n\n");
3064
+ const stream = createSseStream(request, reply, {
3065
+ logger: request.kbLogger ?? platform.logger,
3066
+ serviceId: "rest",
3067
+ route: `${basePath}/workflows/runs/:runId/logs`,
3068
+ keepAliveMs: KEEP_ALIVE_MS,
3069
+ headers: typeof origin === "string" && (origin === "http://localhost:3000" || origin === "http://localhost:5173") ? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" } : void 0
3070
+ });
3030
3071
  const sentLogIds = /* @__PURE__ */ new Set();
3031
3072
  const sendLog = (event) => {
3032
- if (raw.writableEnded) {
3033
- return;
3034
- }
3035
3073
  const ts = event.timestamp ?? (/* @__PURE__ */ new Date()).toISOString();
3036
3074
  const dedup = `${event.type}:${event.jobId ?? ""}:${event.stepId ?? ""}:${ts}`;
3037
3075
  if (sentLogIds.has(dedup)) {
3038
3076
  return;
3039
3077
  }
3040
3078
  sentLogIds.add(dedup);
3041
- raw.write(`event: workflow.log
3042
- `);
3043
- raw.write(`data: ${JSON.stringify({
3079
+ stream.send("workflow.log", {
3044
3080
  type: event.type,
3045
3081
  runId: event.runId,
3046
3082
  jobId: event.jobId,
3047
3083
  stepId: event.stepId,
3048
3084
  payload: event.payload,
3049
3085
  timestamp: ts
3050
- })}
3051
-
3052
- `);
3086
+ });
3053
3087
  };
3054
3088
  let logsAlreadyFinished = false;
3055
3089
  try {
@@ -3074,11 +3108,8 @@ data: {}
3074
3108
  } catch {
3075
3109
  }
3076
3110
  if (logsAlreadyFinished) {
3077
- raw.write(`event: workflow.done
3078
- data: {}
3079
-
3080
- `);
3081
- raw.end();
3111
+ stream.send("workflow.done", {});
3112
+ stream.close("run_already_finished");
3082
3113
  return;
3083
3114
  }
3084
3115
  let idleTimer = null;
@@ -3090,12 +3121,6 @@ data: {}
3090
3121
  cleanup();
3091
3122
  }, idleTimeout);
3092
3123
  };
3093
- const keepAliveTimer = setInterval(() => {
3094
- if (raw.writableEnded) {
3095
- return;
3096
- }
3097
- raw.write(": keep-alive\n\n");
3098
- }, KEEP_ALIVE_MS);
3099
3124
  const unsubscribe = platform.eventBus.subscribe(WORKFLOW_REDIS_CHANNEL, async (rawEvent) => {
3100
3125
  const event = rawEvent;
3101
3126
  if (event.runId !== runId) {
@@ -3112,17 +3137,17 @@ data: {}
3112
3137
  if (idleTimer) {
3113
3138
  clearTimeout(idleTimer);
3114
3139
  }
3115
- clearInterval(keepAliveTimer);
3116
- if (!raw.writableEnded) {
3117
- raw.write(`event: workflow.done
3118
- data: {}
3119
-
3120
- `);
3121
- raw.end();
3122
- }
3140
+ stream.send("workflow.done", {});
3141
+ stream.close("workflow_complete");
3123
3142
  };
3124
3143
  resetIdle();
3125
- request.raw.on("close", cleanup);
3144
+ stream.onCleanup(() => {
3145
+ if (idleTimer) {
3146
+ clearTimeout(idleTimer);
3147
+ }
3148
+ unsubscribe();
3149
+ });
3150
+ await stream.closed;
3126
3151
  }
3127
3152
  });
3128
3153
  }
@@ -3372,7 +3397,7 @@ async function registerCacheRoutes(server, config, registry) {
3372
3397
  }
3373
3398
  });
3374
3399
  }
3375
- async function registerObservabilityRoutes(fastify, config, repoRoot, historicalMetrics, platform16) {
3400
+ async function registerObservabilityRoutes(fastify, config, repoRoot, historicalMetrics, platform17) {
3376
3401
  const basePath = normalizeBasePath(config.basePath);
3377
3402
  const stateBrokerPaths = resolvePaths(basePath, "/observability/state-broker");
3378
3403
  const systemMetricsPaths = resolvePaths(basePath, "/observability/system-metrics");
@@ -3419,7 +3444,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3419
3444
  }
3420
3445
  };
3421
3446
  } catch (error) {
3422
- platform16?.logger.error("Failed to fetch State Broker stats", error instanceof Error ? error : new Error(String(error)));
3447
+ platform17?.logger.error("Failed to fetch State Broker stats", error instanceof Error ? error : new Error(String(error)));
3423
3448
  const isTimeout = error instanceof Error && error.name === "AbortError";
3424
3449
  return reply.code(503).send({
3425
3450
  ok: false,
@@ -3437,7 +3462,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3437
3462
  for (const path2 of systemMetricsPaths) {
3438
3463
  fastify.get(path2, { schema: { tags: ["Observability"], summary: "System resource metrics" } }, async (_request, reply) => {
3439
3464
  try {
3440
- if (!platform16?.cache) {
3465
+ if (!platform17?.cache) {
3441
3466
  return reply.code(503).send({
3442
3467
  ok: false,
3443
3468
  error: {
@@ -3449,18 +3474,18 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3449
3474
  fastify.log.debug("Fetching system metrics from all instances");
3450
3475
  const allMetrics = [];
3451
3476
  try {
3452
- const cacheWithScan = platform16.cache;
3453
- if ("scan" in platform16.cache && typeof cacheWithScan.scan === "function") {
3477
+ const cacheWithScan = platform17.cache;
3478
+ if ("scan" in platform17.cache && typeof cacheWithScan.scan === "function") {
3454
3479
  const keys = await cacheWithScan.scan("system-metrics:*");
3455
3480
  for (const key of keys) {
3456
- const metrics = await platform16.cache.get(key);
3481
+ const metrics = await platform17.cache.get(key);
3457
3482
  if (metrics) {
3458
3483
  allMetrics.push(metrics);
3459
3484
  }
3460
3485
  }
3461
3486
  } else {
3462
3487
  const currentInstanceId = hostname();
3463
- const metrics = await platform16.cache.get(`system-metrics:${currentInstanceId}`);
3488
+ const metrics = await platform17.cache.get(`system-metrics:${currentInstanceId}`);
3464
3489
  if (metrics) {
3465
3490
  allMetrics.push(metrics);
3466
3491
  }
@@ -3469,7 +3494,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3469
3494
  } catch (scanError) {
3470
3495
  fastify.log.warn({ err: scanError }, "Failed to scan platform.cache for system metrics");
3471
3496
  const currentInstanceId = hostname();
3472
- const metrics = await platform16.cache.get(`system-metrics:${currentInstanceId}`);
3497
+ const metrics = await platform17.cache.get(`system-metrics:${currentInstanceId}`);
3473
3498
  if (metrics) {
3474
3499
  allMetrics.push(metrics);
3475
3500
  }
@@ -3517,7 +3542,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3517
3542
  }
3518
3543
  };
3519
3544
  } catch (error) {
3520
- platform16?.logger.error("Failed to fetch system metrics", error instanceof Error ? error : new Error(String(error)));
3545
+ platform17?.logger.error("Failed to fetch system metrics", error instanceof Error ? error : new Error(String(error)));
3521
3546
  return reply.code(500).send({
3522
3547
  ok: false,
3523
3548
  error: {
@@ -3615,7 +3640,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3615
3640
  }
3616
3641
  };
3617
3642
  } catch (error) {
3618
- platform16?.logger.error("Failed to query historical metrics", error instanceof Error ? error : new Error(String(error)), { query });
3643
+ platform17?.logger.error("Failed to query historical metrics", error instanceof Error ? error : new Error(String(error)), { query });
3619
3644
  return reply.code(500).send({
3620
3645
  ok: false,
3621
3646
  error: {
@@ -3706,7 +3731,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3706
3731
  }
3707
3732
  };
3708
3733
  } catch (error) {
3709
- platform16?.logger.error("Failed to query heatmap data", error instanceof Error ? error : new Error(String(error)), { query });
3734
+ platform17?.logger.error("Failed to query heatmap data", error instanceof Error ? error : new Error(String(error)), { query });
3710
3735
  return reply.code(500).send({
3711
3736
  ok: false,
3712
3737
  error: {
@@ -3740,7 +3765,7 @@ async function registerObservabilityRoutes(fastify, config, repoRoot, historical
3740
3765
  }
3741
3766
  }
3742
3767
  }, async (request, reply) => {
3743
- if (!platform16?.llm) {
3768
+ if (!platform17?.llm) {
3744
3769
  return reply.code(503).send({
3745
3770
  ok: false,
3746
3771
  error: {
@@ -3806,15 +3831,15 @@ Provide a clear, actionable response based on the data above. Include:
3806
3831
 
3807
3832
  Be concise but thorough. Use markdown formatting.`;
3808
3833
  fastify.log.debug({ question: body.question, contextLength: contextText.length }, "Calling LLM for insights");
3809
- const result = await platform16.llm.complete(prompt, {
3834
+ const result = await platform17.llm.complete(prompt, {
3810
3835
  systemPrompt: "You are a DevOps and SRE expert assistant. Analyze system metrics and provide actionable insights. Be concise, technical, and helpful.",
3811
3836
  temperature: 0.7,
3812
3837
  maxTokens: 1e3
3813
3838
  });
3814
3839
  const totalTokens = result.usage.promptTokens + result.usage.completionTokens;
3815
3840
  fastify.log.debug({ tokensUsed: totalTokens }, "LLM response received for insights");
3816
- if (platform16.analytics) {
3817
- platform16.analytics.track("ai_insights.chat", {
3841
+ if (platform17.analytics) {
3842
+ platform17.analytics.track("ai_insights.chat", {
3818
3843
  questionLength: body.question.length,
3819
3844
  contextIncluded: Object.keys(contextConfig).filter((k) => contextConfig[k]),
3820
3845
  timeRange: contextConfig.timeRange,
@@ -3843,9 +3868,9 @@ Be concise but thorough. Use markdown formatting.`;
3843
3868
  }
3844
3869
  };
3845
3870
  } catch (error) {
3846
- platform16?.logger.error("Failed to generate insights", error instanceof Error ? error : new Error(String(error)));
3847
- if (platform16?.analytics) {
3848
- platform16.analytics.track("ai_insights.error", {
3871
+ platform17?.logger.error("Failed to generate insights", error instanceof Error ? error : new Error(String(error)));
3872
+ if (platform17?.analytics) {
3873
+ platform17.analytics.track("ai_insights.error", {
3849
3874
  error: error instanceof Error ? error.message : "Unknown error",
3850
3875
  questionLength: request.body?.question?.length ?? 0
3851
3876
  }).catch(() => {
@@ -5353,55 +5378,21 @@ async function registerLogRoutes(server, config, eventHub) {
5353
5378
  message: "Logger adapter does not support streaming. Enable logRingBuffer in kb.config.json"
5354
5379
  });
5355
5380
  }
5356
- reply.hijack();
5357
- let streamClosed = false;
5358
5381
  const origin = request.headers.origin;
5359
- if (origin === "http://localhost:3000" || origin === "http://localhost:5173") {
5360
- reply.raw.setHeader("Access-Control-Allow-Origin", origin);
5361
- reply.raw.setHeader("Access-Control-Allow-Credentials", "true");
5362
- } else {
5363
- reply.raw.setHeader("Access-Control-Allow-Origin", "*");
5364
- }
5365
- reply.raw.setHeader("Content-Type", "text/event-stream");
5366
- reply.raw.setHeader("Cache-Control", "no-cache, no-transform");
5367
- reply.raw.setHeader("Connection", "keep-alive");
5368
- try {
5369
- reply.raw.flushHeaders?.();
5370
- reply.raw.write(": connected\n\n");
5371
- } catch (err) {
5372
- streamClosed = true;
5373
- return;
5374
- }
5375
- const unsubscribe = platform.logs.subscribe((log) => {
5376
- if (!streamClosed && !reply.raw.writableEnded && !reply.raw.destroyed) {
5377
- try {
5378
- const frontendLog = toFrontendLogRecord(log);
5379
- reply.raw.write(`event: log
5380
- `);
5381
- reply.raw.write(`data: ${JSON.stringify(frontendLog)}
5382
-
5383
- `);
5384
- } catch (err) {
5385
- streamClosed = true;
5386
- }
5387
- }
5382
+ const stream = createSseStream(request, reply, {
5383
+ logger: request.kbLogger ?? platform.logger,
5384
+ serviceId: "rest",
5385
+ route: "/api/v1/logs/stream",
5386
+ // This stream carries logs, so event-level telemetry would feed it
5387
+ // back into itself. Lifecycle + close summary remain enabled.
5388
+ logEvents: false,
5389
+ headers: origin === "http://localhost:3000" || origin === "http://localhost:5173" ? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true" } : { "Access-Control-Allow-Origin": "*" }
5388
5390
  });
5389
- const cleanup = () => {
5390
- if (!streamClosed) {
5391
- streamClosed = true;
5392
- unsubscribe();
5393
- try {
5394
- if (!reply.raw.writableEnded && !reply.raw.destroyed) {
5395
- reply.raw.end();
5396
- }
5397
- } catch (err) {
5398
- }
5399
- }
5400
- };
5401
- request.raw.on("close", cleanup);
5402
- request.raw.on("error", cleanup);
5403
- await new Promise(() => {
5391
+ const unsubscribe = platform.logs.subscribe((log) => {
5392
+ stream.send("log", toFrontendLogRecord(log));
5404
5393
  });
5394
+ stream.onCleanup(unsubscribe);
5395
+ await stream.closed;
5405
5396
  }
5406
5397
  );
5407
5398
  server.get(
@@ -6565,7 +6556,7 @@ async function bootstrap(cwd = process.cwd()) {
6565
6556
  });
6566
6557
  }
6567
6558
  async function startRestApi({
6568
- platform: platform16,
6559
+ platform: platform17,
6569
6560
  projectRoot: repoRoot,
6570
6561
  platformRoot,
6571
6562
  logger: serviceLogger
@@ -6604,7 +6595,7 @@ async function startRestApi({
6604
6595
  platformRoot: platformRoot !== repoRoot ? platformRoot : void 0,
6605
6596
  cache: {
6606
6597
  ttlMs: snapshotTTL,
6607
- adapter: platform16.cache
6598
+ adapter: platform17.cache
6608
6599
  }
6609
6600
  });
6610
6601
  restDomainOperationMetrics.recordOperation(
@@ -6633,7 +6624,7 @@ async function startRestApi({
6633
6624
  () => metricsCollector.getActiveRequests()
6634
6625
  );
6635
6626
  await metricsCollector2.start(1e4, 6e4);
6636
- const restTransport = platform16.getAdapter("serviceTransport");
6627
+ const restTransport = platform17.getAdapter("serviceTransport");
6637
6628
  const restAddr = restTransport?.listenAddress?.("rest");
6638
6629
  const netOffset = Number(process.env.KB_NET_OFFSET) || 0;
6639
6630
  const listenPort = restAddr && "port" in restAddr ? restAddr.port : config.port + netOffset;