@juspay/neurolink 9.92.3 → 9.93.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +379 -382
  3. package/dist/cli/commands/proxy.d.ts +5 -3
  4. package/dist/cli/commands/proxy.js +360 -69
  5. package/dist/cli/commands/proxyAnalyze.d.ts +3 -0
  6. package/dist/cli/commands/proxyAnalyze.js +147 -0
  7. package/dist/cli/parser.js +3 -1
  8. package/dist/lib/proxy/proxyActivity.d.ts +2 -2
  9. package/dist/lib/proxy/proxyActivity.js +49 -9
  10. package/dist/lib/proxy/proxyAnalysis.d.ts +3 -0
  11. package/dist/lib/proxy/proxyAnalysis.js +454 -0
  12. package/dist/lib/proxy/proxyHealth.d.ts +4 -0
  13. package/dist/lib/proxy/proxyHealth.js +21 -0
  14. package/dist/lib/proxy/proxyLifecycle.d.ts +8 -0
  15. package/dist/lib/proxy/proxyLifecycle.js +333 -0
  16. package/dist/lib/proxy/requestLogger.js +6 -0
  17. package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
  18. package/dist/lib/proxy/sseInterceptor.js +6 -5
  19. package/dist/lib/proxy/streamOutcome.d.ts +3 -1
  20. package/dist/lib/proxy/streamOutcome.js +77 -0
  21. package/dist/lib/proxy/updateCoordinator.d.ts +7 -0
  22. package/dist/lib/proxy/updateCoordinator.js +60 -0
  23. package/dist/lib/proxy/updateState.d.ts +4 -0
  24. package/dist/lib/proxy/updateState.js +51 -0
  25. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
  26. package/dist/lib/server/routes/claudeProxyRoutes.js +137 -35
  27. package/dist/lib/types/cli.d.ts +7 -0
  28. package/dist/lib/types/proxy.d.ts +269 -0
  29. package/dist/proxy/proxyActivity.d.ts +2 -2
  30. package/dist/proxy/proxyActivity.js +49 -9
  31. package/dist/proxy/proxyAnalysis.d.ts +3 -0
  32. package/dist/proxy/proxyAnalysis.js +453 -0
  33. package/dist/proxy/proxyHealth.d.ts +4 -0
  34. package/dist/proxy/proxyHealth.js +21 -0
  35. package/dist/proxy/proxyLifecycle.d.ts +8 -0
  36. package/dist/proxy/proxyLifecycle.js +332 -0
  37. package/dist/proxy/requestLogger.js +6 -0
  38. package/dist/proxy/sseInterceptor.d.ts +9 -1
  39. package/dist/proxy/sseInterceptor.js +6 -5
  40. package/dist/proxy/streamOutcome.d.ts +3 -1
  41. package/dist/proxy/streamOutcome.js +77 -0
  42. package/dist/proxy/updateCoordinator.d.ts +7 -0
  43. package/dist/proxy/updateCoordinator.js +59 -0
  44. package/dist/proxy/updateState.d.ts +4 -0
  45. package/dist/proxy/updateState.js +51 -0
  46. package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
  47. package/dist/server/routes/claudeProxyRoutes.js +137 -35
  48. package/dist/types/cli.d.ts +7 -0
  49. package/dist/types/proxy.d.ts +269 -0
  50. package/package.json +4 -1
@@ -10,7 +10,8 @@
10
10
  * (generate/stream), with an optional ModelRouter for model remapping.
11
11
  */
12
12
  import type { CommandModule } from "yargs";
13
- import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs } from "../../lib/types/index.js";
13
+ import type { Hono } from "hono";
14
+ import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
14
15
  import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
15
16
  export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
16
17
  export declare function createProxyStartApp(params: {
@@ -24,9 +25,10 @@ export declare function createProxyStartApp(params: {
24
25
  primaryAccountKey: string | undefined;
25
26
  accountAllowlist: AccountAllowlist | undefined;
26
27
  runtimeConfigStore?: ProxyRuntimeConfigStore;
28
+ updateControlToken?: string;
27
29
  }): Promise<{
28
- app: import("hono").Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
29
- readiness: import("../../lib/types/proxy.js").ProxyReadinessState;
30
+ app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
31
+ readiness: ProxyReadinessState;
30
32
  }>;
31
33
  export declare const proxyStartCommand: CommandModule<object, ProxyStartArgs>;
32
34
  export declare const proxyStatusCommand: CommandModule<object, ProxyStatusArgs>;
@@ -14,16 +14,19 @@ import { homedir } from "node:os";
14
14
  import { dirname, join, resolve } from "node:path";
15
15
  import chalk from "chalk";
16
16
  import ora from "ora";
17
- import { buildProxyHealthResponse, createProxyReadinessState, markProxyReady, waitForProxyReadiness, } from "../../lib/proxy/proxyHealth.js";
17
+ import { buildProxyHealthResponse, createProxyReadinessState, markProxyDrainingForUpdate, markProxyReady, resumeProxyConnections, waitForProxyReadiness, } from "../../lib/proxy/proxyHealth.js";
18
18
  import { logger } from "../../lib/utils/logger.js";
19
+ import { withTimeout } from "../../lib/utils/async/withTimeout.js";
19
20
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
20
21
  import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
21
22
  import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
22
23
  import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../lib/proxy/accountSelection.js";
23
24
  import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../lib/proxy/proxyActivity.js";
25
+ import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../lib/proxy/proxyLifecycle.js";
24
26
  import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, validateInstalledVersion, } from "../../lib/proxy/globalInstaller.js";
25
27
  import { startUpdaterWorkerSupervisor } from "../../lib/proxy/updaterSupervisor.js";
26
- import { abandonPendingUpdate, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
28
+ import { waitForProxyUpdateWindow } from "../../lib/proxy/updateCoordinator.js";
29
+ import { abandonPendingUpdate, clearUpdateDeferral, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateDeferred, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
27
30
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
28
31
  import { createRequire } from "node:module";
29
32
  import { fileURLToPath } from "node:url";
@@ -31,6 +34,9 @@ import packageJson from "../../../package.json" with { type: "json" };
31
34
  const _require = createRequire(import.meta.url);
32
35
  const PROXY_VERSION = packageJson.version;
33
36
  const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
37
+ const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
38
+ const PROXY_UPDATE_CONTROL_TOKEN = process.env.NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN?.trim() ||
39
+ crypto.randomUUID();
34
40
  // =============================================================================
35
41
  // STATE MANAGEMENT
36
42
  // =============================================================================
@@ -367,16 +373,52 @@ async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
367
373
  return null;
368
374
  }
369
375
  }
370
- function isSafeUpdateWindow(activity, quietThresholdMs, nowMs = Date.now()) {
371
- if (activity.activeRequests > 0) {
372
- return false;
376
+ async function setProxyUpdateDrain(host, port, draining) {
377
+ const confirmState = async () => {
378
+ try {
379
+ const response = await fetch(`http://${host}:${port}/status`, {
380
+ signal: AbortSignal.timeout(3_000),
381
+ });
382
+ if (!response.ok) {
383
+ return false;
384
+ }
385
+ const payload = (await response.json());
386
+ return payload.health?.drainingForUpdate === draining;
387
+ }
388
+ catch {
389
+ return false;
390
+ }
391
+ };
392
+ try {
393
+ const response = await fetch(`http://${host}:${port}/internal/update-control`, {
394
+ method: "POST",
395
+ headers: {
396
+ "content-type": "application/json",
397
+ "x-neurolink-update-token": PROXY_UPDATE_CONTROL_TOKEN,
398
+ },
399
+ body: JSON.stringify({ action: draining ? "drain" : "resume" }),
400
+ signal: AbortSignal.timeout(5_000),
401
+ });
402
+ if (!response.ok) {
403
+ return confirmState();
404
+ }
405
+ const payload = (await response.json());
406
+ return payload.draining === draining;
407
+ }
408
+ catch {
409
+ return confirmState();
373
410
  }
374
- if (!activity.lastActivityAt) {
375
- return true;
411
+ }
412
+ async function resumeProxyUpdateDrain(host, port, attempts = 3) {
413
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
414
+ if (await setProxyUpdateDrain(host, port, false)) {
415
+ return true;
416
+ }
417
+ if (attempt < attempts) {
418
+ await sleep(1_000);
419
+ }
376
420
  }
377
- const lastActivityMs = Date.parse(activity.lastActivityAt);
378
- return (Number.isFinite(lastActivityMs) &&
379
- nowMs - lastActivityMs >= quietThresholdMs);
421
+ return false;
380
422
  }
381
423
  // ---------------------------------------------------------------------------
382
424
  // Stable entrypoint for launchd
@@ -553,7 +595,10 @@ function spawnProxyUpdater(host, port, parentPid) {
553
595
  const child = spawn(process.execPath, args, {
554
596
  detached: true,
555
597
  stdio: ["ignore", logFd, logFd],
556
- env: process.env,
598
+ env: {
599
+ ...process.env,
600
+ NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN: PROXY_UPDATE_CONTROL_TOKEN,
601
+ },
557
602
  });
558
603
  child.once("error", (error) => {
559
604
  logger.always(`[proxy] updater worker error pid=${child.pid ?? "unknown"}: ${error.message}`);
@@ -643,6 +688,20 @@ export function mapClaudeErrorTypeToStatus(errorType) {
643
688
  return 502;
644
689
  }
645
690
  }
691
+ function getProxyRuntimeErrorCode(error) {
692
+ let current = error;
693
+ for (let depth = 0; depth < 3; depth += 1) {
694
+ if (!current || typeof current !== "object") {
695
+ return undefined;
696
+ }
697
+ const code = current.code;
698
+ if (typeof code === "string") {
699
+ return code;
700
+ }
701
+ current = current.cause;
702
+ }
703
+ return undefined;
704
+ }
646
705
  async function ensureProxyStartAllowed(spinner) {
647
706
  const ignoreLaunchd = process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "1" ||
648
707
  process.env.NEUROLINK_PROXY_IGNORE_LAUNCHD === "true";
@@ -710,6 +769,128 @@ async function createProxyNeurolinkRuntime(logsDir) {
710
769
  cleanupLogs(7, 500);
711
770
  return { neurolink, cleanupLogs };
712
771
  }
772
+ function registerProxyRequestTracking(app, requestMetadata, readiness) {
773
+ app.use("/v1/*", async (c, next) => {
774
+ const startedMonotonicMs = performance.now();
775
+ const contentLengthHeader = c.req.raw.headers.get("content-length");
776
+ const rawContentLength = contentLengthHeader === null ? Number.NaN : Number(contentLengthHeader);
777
+ const requestBytes = Number.isFinite(rawContentLength) && rawContentLength >= 0
778
+ ? rawContentLength
779
+ : undefined;
780
+ const sessionId = c.req.raw.headers.get("x-neurolink-session-id") ??
781
+ c.req.raw.headers.get("x-claude-code-session-id") ??
782
+ undefined;
783
+ const sessionHash = hashProxyLifecycleSessionId(sessionId);
784
+ const metadata = {
785
+ requestId: crypto.randomUUID(),
786
+ method: c.req.method,
787
+ path: c.req.path,
788
+ startedAt: Date.now(),
789
+ model: "-",
790
+ stream: false,
791
+ toolCount: 0,
792
+ rejectForUpdate: readiness.drainingForUpdate,
793
+ };
794
+ requestMetadata.set(c.req.raw, metadata);
795
+ const finishActivity = metadata.rejectForUpdate
796
+ ? () => undefined
797
+ : beginProxyRequest();
798
+ const finish = () => {
799
+ finishActivity();
800
+ requestMetadata.delete(c.req.raw);
801
+ };
802
+ // The route adapter populates model/stream/toolCount after parsing. Omit
803
+ // them at acceptance instead of publishing misleading placeholder values;
804
+ // subsequent events carry the parsed metadata under the same request ID.
805
+ logProxyLifecycleEvent({
806
+ event: "request_accepted",
807
+ requestId: metadata.requestId,
808
+ method: metadata.method,
809
+ path: metadata.path,
810
+ sessionHash,
811
+ requestBytes,
812
+ elapsedMs: 0,
813
+ monotonicMs: startedMonotonicMs,
814
+ });
815
+ try {
816
+ await next();
817
+ const responseStatus = c.res.status;
818
+ logProxyLifecycleEvent({
819
+ event: "response_headers",
820
+ requestId: metadata.requestId,
821
+ method: metadata.method,
822
+ path: metadata.path,
823
+ model: metadata.model,
824
+ stream: metadata.stream,
825
+ toolCount: metadata.toolCount,
826
+ sessionHash,
827
+ requestBytes,
828
+ responseStatus,
829
+ elapsedMs: performance.now() - startedMonotonicMs,
830
+ });
831
+ c.res = trackProxyResponse(c.res, finish, {
832
+ onFirstChunk: ({ observedBodyBytes, responseChunks }) => {
833
+ logProxyLifecycleEvent({
834
+ event: "response_first_chunk",
835
+ requestId: metadata.requestId,
836
+ method: metadata.method,
837
+ path: metadata.path,
838
+ model: metadata.model,
839
+ stream: metadata.stream,
840
+ toolCount: metadata.toolCount,
841
+ sessionHash,
842
+ requestBytes,
843
+ responseStatus,
844
+ observedBodyBytes,
845
+ responseChunks,
846
+ elapsedMs: performance.now() - startedMonotonicMs,
847
+ });
848
+ },
849
+ onTerminal: ({ outcome, observedBodyBytes, responseChunks }) => {
850
+ logProxyLifecycleEvent({
851
+ event: "request_terminal",
852
+ requestId: metadata.requestId,
853
+ method: metadata.method,
854
+ path: metadata.path,
855
+ model: metadata.model,
856
+ stream: metadata.stream,
857
+ toolCount: metadata.toolCount,
858
+ sessionHash,
859
+ requestBytes,
860
+ responseStatus,
861
+ observedBodyBytes,
862
+ responseChunks,
863
+ elapsedMs: performance.now() - startedMonotonicMs,
864
+ terminalOutcome: outcome,
865
+ errorType: metadata.terminalErrorType,
866
+ errorCode: metadata.terminalErrorCode,
867
+ });
868
+ },
869
+ });
870
+ }
871
+ catch (error) {
872
+ // Keep metadata available to app.onError, which records the client-facing
873
+ // failure with the same request ID before deleting the WeakMap entry.
874
+ finishActivity();
875
+ logProxyLifecycleEvent({
876
+ event: "request_terminal",
877
+ requestId: metadata.requestId,
878
+ method: metadata.method,
879
+ path: metadata.path,
880
+ model: metadata.model,
881
+ stream: metadata.stream,
882
+ toolCount: metadata.toolCount,
883
+ sessionHash,
884
+ requestBytes,
885
+ elapsedMs: performance.now() - startedMonotonicMs,
886
+ terminalOutcome: "handler_error",
887
+ errorType: error instanceof Error ? error.name : "unknown_error",
888
+ errorCode: getProxyRuntimeErrorCode(error),
889
+ });
890
+ throw error;
891
+ }
892
+ });
893
+ }
713
894
  export async function createProxyStartApp(params) {
714
895
  const { createClaudeProxyRoutes } = await import("../../lib/server/routes/claudeProxyRoutes.js");
715
896
  const { createOpenAIProxyRoutes } = await import("../../lib/server/routes/openaiProxyRoutes.js");
@@ -719,7 +900,9 @@ export async function createProxyStartApp(params) {
719
900
  const app = new Hono();
720
901
  const readiness = createProxyReadinessState();
721
902
  const requestMetadata = new WeakMap();
722
- const recordRuntimeError = async (metadata, status, errorType, errorMessage, clientMessage = errorMessage, clientErrorType = errorType) => {
903
+ const recordRuntimeError = async (metadata, status, errorType, errorMessage, options) => {
904
+ const clientMessage = options?.clientMessage ?? errorMessage;
905
+ const clientErrorType = options?.clientErrorType ?? errorType;
723
906
  recordFinalError(status);
724
907
  await Promise.all([
725
908
  logRequest({
@@ -736,6 +919,7 @@ export async function createProxyStartApp(params) {
736
919
  responseTimeMs: Date.now() - metadata.startedAt,
737
920
  errorType,
738
921
  errorMessage,
922
+ ...(options?.errorCode ? { errorCode: options.errorCode } : {}),
739
923
  }),
740
924
  logBodyCapture({
741
925
  timestamp: new Date().toISOString(),
@@ -769,7 +953,13 @@ export async function createProxyStartApp(params) {
769
953
  stream: false,
770
954
  toolCount: 0,
771
955
  };
772
- await recordRuntimeError(metadata, 502, "unhandled_proxy_error", errMsg, "Proxy internal error", "api_error");
956
+ metadata.terminalErrorType = "unhandled_proxy_error";
957
+ metadata.terminalErrorCode = getProxyRuntimeErrorCode(err);
958
+ await recordRuntimeError(metadata, 502, "unhandled_proxy_error", errMsg, {
959
+ clientMessage: "Proxy internal error",
960
+ clientErrorType: "api_error",
961
+ errorCode: metadata.terminalErrorCode,
962
+ });
773
963
  requestMetadata.delete(c.req.raw);
774
964
  return c.json({
775
965
  type: "error",
@@ -779,31 +969,34 @@ export async function createProxyStartApp(params) {
779
969
  },
780
970
  }, 502);
781
971
  });
782
- app.use("/v1/*", async (c, next) => {
783
- const metadata = {
784
- requestId: crypto.randomUUID(),
785
- method: c.req.method,
786
- path: c.req.path,
787
- startedAt: Date.now(),
788
- model: "-",
789
- stream: false,
790
- toolCount: 0,
791
- };
792
- requestMetadata.set(c.req.raw, metadata);
793
- const finishActivity = beginProxyRequest();
794
- const finish = () => {
795
- finishActivity();
796
- requestMetadata.delete(c.req.raw);
797
- };
798
- try {
799
- await next();
800
- c.res = trackProxyResponse(c.res, finish);
972
+ app.post("/internal/update-control", async (c) => {
973
+ const suppliedToken = c.req.header("x-neurolink-update-token");
974
+ const expectedToken = params.updateControlToken ?? PROXY_UPDATE_CONTROL_TOKEN;
975
+ if (!suppliedToken || suppliedToken !== expectedToken) {
976
+ return c.json({ error: "not_found" }, 404);
977
+ }
978
+ const payload = await c.req
979
+ .json()
980
+ .catch(() => ({ action: undefined }));
981
+ if (payload.action === "drain") {
982
+ if (!markProxyDrainingForUpdate(readiness)) {
983
+ return c.json({ error: "proxy_not_ready" }, 409);
984
+ }
801
985
  }
802
- catch (error) {
803
- finishActivity();
804
- throw error;
986
+ else if (payload.action === "resume") {
987
+ if (!resumeProxyConnections(readiness)) {
988
+ return c.json({ error: "proxy_not_ready" }, 409);
989
+ }
805
990
  }
991
+ else {
992
+ return c.json({ error: "invalid_action" }, 400);
993
+ }
994
+ return c.json({
995
+ draining: readiness.drainingForUpdate,
996
+ acceptingConnections: readiness.acceptingConnections,
997
+ });
806
998
  });
999
+ registerProxyRequestTracking(app, requestMetadata, readiness);
807
1000
  const runtimeConfigStore = params.runtimeConfigStore;
808
1001
  const runtimeConfigProvider = runtimeConfigStore
809
1002
  ? () => runtimeConfigStore.getSnapshot()
@@ -819,6 +1012,24 @@ export async function createProxyStartApp(params) {
819
1012
  for (const route of allProxyRoutes) {
820
1013
  const method = route.method.toLowerCase();
821
1014
  app[method](route.path, async (c) => {
1015
+ const metadata = requestMetadata.get(c.req.raw);
1016
+ if (metadata?.rejectForUpdate) {
1017
+ if (metadata) {
1018
+ metadata.terminalErrorType = "proxy_draining";
1019
+ await recordRuntimeError(metadata, 503, "proxy_draining", "Proxy is draining for an automatic update", {
1020
+ clientMessage: "Proxy is restarting; retry shortly",
1021
+ clientErrorType: "overloaded_error",
1022
+ });
1023
+ }
1024
+ c.header("Retry-After", "2");
1025
+ return c.json({
1026
+ type: "error",
1027
+ error: {
1028
+ type: "overloaded_error",
1029
+ message: "Proxy is restarting; retry shortly",
1030
+ },
1031
+ }, 503);
1032
+ }
822
1033
  const emptyBody = {};
823
1034
  let body;
824
1035
  let rawBody;
@@ -849,7 +1060,6 @@ export async function createProxyStartApp(params) {
849
1060
  const toolCount = Array.isArray(bodyRec?.tools)
850
1061
  ? bodyRec.tools.length
851
1062
  : 0;
852
- const metadata = requestMetadata.get(c.req.raw);
853
1063
  if (metadata) {
854
1064
  metadata.model = String(model);
855
1065
  metadata.stream = stream === "stream";
@@ -1042,6 +1252,9 @@ export async function createProxyStartApp(params) {
1042
1252
  lastActivityAt: activity.lastActivityAt?.toISOString() ?? null,
1043
1253
  };
1044
1254
  })(),
1255
+ observability: {
1256
+ lifecycle: getProxyLifecycleLoggerSnapshot(),
1257
+ },
1045
1258
  autoUpdate: {
1046
1259
  enabled: isProxyAutoUpdateEnabled(),
1047
1260
  updaterPid: runtimeState?.updaterPid ?? null,
@@ -1051,6 +1264,7 @@ export async function createProxyStartApp(params) {
1051
1264
  liveVersion: PROXY_VERSION,
1052
1265
  latestVersion: updateState?.lastCheckVersion || null,
1053
1266
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
1267
+ deferredUpdate: updateState?.deferredUpdate ?? null,
1054
1268
  lastCheckAt: updateState?.lastCheckAt ?? null,
1055
1269
  lastUpdateAt: updateState?.lastUpdateAt ?? null,
1056
1270
  lastUpdateVersion: updateState?.lastUpdateVersion ?? null,
@@ -1339,6 +1553,12 @@ function registerProxyShutdownHandlers(params) {
1339
1553
  exitCode = 1;
1340
1554
  logger.error(`[proxy] failed to drain server during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1341
1555
  }
1556
+ try {
1557
+ await withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown");
1558
+ }
1559
+ catch (error) {
1560
+ logger.debug(`[proxy] lifecycle metadata flush failed during shutdown: ${error instanceof Error ? error.message : String(error)}`);
1561
+ }
1342
1562
  try {
1343
1563
  const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../lib/services/server/ai/observability/instrumentation.js");
1344
1564
  await flushOpenTelemetry();
@@ -1848,6 +2068,7 @@ export const proxyStatusCommand = {
1848
2068
  updaterRunning: false,
1849
2069
  latestVersion: updateState?.lastCheckVersion || null,
1850
2070
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
2071
+ deferredUpdate: updateState?.deferredUpdate ?? null,
1851
2072
  lastUpdateFailure: updateState?.lastFailure ?? null,
1852
2073
  };
1853
2074
  if (state && isProcessRunning(state.pid)) {
@@ -1927,6 +2148,12 @@ export const proxyStatusCommand = {
1927
2148
  if (status.pendingRestartVersion) {
1928
2149
  logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
1929
2150
  }
2151
+ if (status.deferredUpdate) {
2152
+ const active = status.deferredUpdate.activeRequests === null
2153
+ ? "unknown activity"
2154
+ : `${status.deferredUpdate.activeRequests} active request(s)`;
2155
+ logger.always(` ${chalk.bold("Deferred:")} ${chalk.yellow(`v${status.deferredUpdate.version} ${status.deferredUpdate.reason} (${active})`)}`);
2156
+ }
1930
2157
  if (status.latestVersion) {
1931
2158
  logger.always(` ${chalk.bold("Latest:")} ${chalk.cyan(`v${status.latestVersion}`)}`);
1932
2159
  }
@@ -2109,6 +2336,10 @@ export const proxyGuardCommand = {
2109
2336
  // foreground fail-open guard remains cleanup-only.
2110
2337
  const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
2111
2338
  const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
2339
+ const NATURAL_WINDOW_WAIT_MS = 10 * 60 * 1000; // prefer no admission pause
2340
+ const UPDATE_DRAIN_TIMEOUT_MS = 30 * 60 * 1000; // preserve long streams
2341
+ const UPDATE_RETRY_DELAY_MS = 5 * 60 * 1000;
2342
+ const UPDATE_ACTIVITY_POLL_MS = 10 * 1000;
2112
2343
  const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
2113
2344
  // Get running version from /health endpoint (with timeout to avoid hanging)
2114
2345
  let runningVersion = PROXY_VERSION; // fallback
@@ -2131,6 +2362,7 @@ export const proxyGuardCommand = {
2131
2362
  let guardStopping = false;
2132
2363
  let updateCheckTimeout;
2133
2364
  let updateCheckInterval;
2365
+ let updateRetryTimeout;
2134
2366
  const stopUpdateChecks = () => {
2135
2367
  guardStopping = true;
2136
2368
  if (updateCheckTimeout) {
@@ -2141,6 +2373,10 @@ export const proxyGuardCommand = {
2141
2373
  clearInterval(updateCheckInterval);
2142
2374
  updateCheckInterval = undefined;
2143
2375
  }
2376
+ if (updateRetryTimeout) {
2377
+ clearTimeout(updateRetryTimeout);
2378
+ updateRetryTimeout = undefined;
2379
+ }
2144
2380
  };
2145
2381
  /** Keep state-write failures observable without terminating the updater. */
2146
2382
  const persistUpdaterState = (operation, action) => {
@@ -2159,41 +2395,93 @@ export const proxyGuardCommand = {
2159
2395
  }
2160
2396
  updateInProgress = true;
2161
2397
  let updateVersion = runningVersion;
2398
+ let drainActive = false;
2162
2399
  try {
2163
2400
  // Lazy-load update modules so they're only imported at check time
2164
2401
  const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
2165
2402
  // 1. Check for update
2166
- const result = await checkForUpdate(runningVersion);
2403
+ let result = await checkForUpdate(runningVersion);
2167
2404
  updateVersion = result.latestVersion;
2168
2405
  persistUpdaterState("record update check", () => recordCheck(result.latestVersion));
2169
2406
  if (!result.updateAvailable) {
2407
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2170
2408
  return;
2171
2409
  }
2172
- const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2173
- if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
2410
+ const initiallyPendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2411
+ if (isVersionSuppressed(result.latestVersion) &&
2412
+ !initiallyPendingRestart) {
2174
2413
  logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
2175
2414
  return;
2176
2415
  }
2177
2416
  logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
2178
- // 2. Wait for an exact runtime-idle window. Never force an update while
2179
- // a request or stream is active; the next worker cycle can try again.
2180
- const quietPollMs = 10_000; // check every 10s
2181
- while (!guardStopping) {
2182
- if (getProcessStatus(parentPid) === "not_running") {
2183
- logger.always(`[updater] parent process died while waiting for idle traffic`);
2184
- return;
2417
+ // 2. Prefer a naturally quiet period. Sustained traffic is handled by
2418
+ // a bounded graceful drain so update checks cannot wait forever.
2419
+ let lastDeferralSignature = "";
2420
+ const waitForWindow = async (quietWaitMs) => {
2421
+ const window = await waitForProxyUpdateWindow({
2422
+ quietThresholdMs: QUIET_THRESHOLD_MS,
2423
+ quietWaitMs,
2424
+ drainWaitMs: UPDATE_DRAIN_TIMEOUT_MS,
2425
+ pollIntervalMs: UPDATE_ACTIVITY_POLL_MS,
2426
+ getActivity: () => getProxyRuntimeActivity(host, port),
2427
+ setDraining: async (draining) => {
2428
+ const changed = await setProxyUpdateDrain(host, port, draining);
2429
+ if (changed) {
2430
+ logger.always(`[updater] ${draining ? "draining new inference requests" : "inference admission resumed"}`);
2431
+ }
2432
+ return changed;
2433
+ },
2434
+ isStopping: () => guardStopping,
2435
+ isParentAlive: () => getProcessStatus(parentPid) !== "not_running",
2436
+ onPhase: (phase, activity) => {
2437
+ const reason = phase === "waiting_for_quiet" && !activity
2438
+ ? "activity_unavailable"
2439
+ : phase;
2440
+ const signature = `${reason}:${activity?.activeRequests ?? "unknown"}`;
2441
+ if (signature !== lastDeferralSignature) {
2442
+ lastDeferralSignature = signature;
2443
+ persistUpdaterState("record update deferral", () => recordUpdateDeferred(result.latestVersion, reason, activity?.activeRequests ?? null));
2444
+ }
2445
+ logger.debug(`[updater] ${reason} (${activity?.activeRequests ?? "unknown"} active requests)`);
2446
+ },
2447
+ });
2448
+ drainActive = drainActive || window.draining;
2449
+ return window;
2450
+ };
2451
+ let updateWindow = await waitForWindow(NATURAL_WINDOW_WAIT_MS);
2452
+ if (!updateWindow.ready) {
2453
+ if (updateWindow.reason === "drain_failed") {
2454
+ persistUpdaterState("record unavailable update drain", () => recordUpdateDeferred(result.latestVersion, "drain_unavailable", null));
2185
2455
  }
2186
- const activity = await getProxyRuntimeActivity(host, port);
2187
- if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2188
- logger.always(`[updater] traffic idle, proceeding with update`);
2189
- break;
2456
+ scheduleUpdateRetry();
2457
+ return;
2458
+ }
2459
+ // Close the small race between observing zero activity and package
2460
+ // mutation by holding admission closed through install and restart.
2461
+ if (!drainActive) {
2462
+ updateWindow = await waitForWindow(0);
2463
+ if (!updateWindow.ready) {
2464
+ scheduleUpdateRetry();
2465
+ return;
2190
2466
  }
2191
- logger.debug(`[updater] waiting for idle traffic (${activity?.activeRequests ?? "unknown"} active requests)`);
2192
- await new Promise((r) => setTimeout(r, quietPollMs));
2193
2467
  }
2194
- if (guardStopping) {
2468
+ // Refresh after waiting so a long deferral can never install a stale
2469
+ // target while a newer release is already available.
2470
+ const refreshedResult = await checkForUpdate(runningVersion);
2471
+ result = refreshedResult;
2472
+ updateVersion = result.latestVersion;
2473
+ persistUpdaterState("record refreshed update check", () => recordCheck(result.latestVersion));
2474
+ if (!result.updateAvailable) {
2475
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2195
2476
  return;
2196
2477
  }
2478
+ const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2479
+ if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
2480
+ logger.debug(`[guard] refreshed version ${result.latestVersion} is suppressed, skipping`);
2481
+ return;
2482
+ }
2483
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2484
+ logger.always(`[updater] safe update window acquired for v${result.latestVersion}`);
2197
2485
  // 3. Install update (validate version string before passing to shell)
2198
2486
  if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
2199
2487
  const message = `invalid version format: ${result.latestVersion}`;
@@ -2266,22 +2554,8 @@ export const proxyGuardCommand = {
2266
2554
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2267
2555
  return;
2268
2556
  }
2269
- // Installation can overlap new traffic. Re-establish a full idle window
2270
- // before restart so no accepted request or long stream is interrupted.
2271
- while (!guardStopping) {
2272
- if (getProcessStatus(parentPid) === "not_running") {
2273
- return;
2274
- }
2275
- const activity = await getProxyRuntimeActivity(host, port);
2276
- if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2277
- break;
2278
- }
2279
- logger.debug(`[updater] update installed; deferring restart (${activity?.activeRequests ?? "unknown"} active requests)`);
2280
- await sleep(10_000);
2281
- }
2282
- if (guardStopping) {
2283
- return;
2284
- }
2557
+ // Admission has remained closed since active requests reached zero, so
2558
+ // restart cannot interrupt an accepted request or long-lived stream.
2285
2559
  // Signal the health loop to not exit when it detects
2286
2560
  // the parent PID is gone — we're intentionally restarting.
2287
2561
  updateRestartInProgress = true;
@@ -2302,6 +2576,7 @@ export const proxyGuardCommand = {
2302
2576
  persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", msg));
2303
2577
  return;
2304
2578
  }
2579
+ drainActive = false;
2305
2580
  // 5. Wait for healthy restart
2306
2581
  let healthy = false;
2307
2582
  const restartStart = Date.now();
@@ -2343,9 +2618,25 @@ export const proxyGuardCommand = {
2343
2618
  persistUpdaterState("record update failure", () => recordUpdateFailure(updateVersion, "check", message));
2344
2619
  }
2345
2620
  finally {
2621
+ if (drainActive && !updateRestartInProgress) {
2622
+ const resumed = await resumeProxyUpdateDrain(host, port);
2623
+ if (!resumed && getProcessStatus(parentPid) !== "not_running") {
2624
+ logger.always(`[updater] WARNING: failed to resume inference admission after deferred update`);
2625
+ }
2626
+ }
2346
2627
  updateInProgress = false;
2347
2628
  }
2348
2629
  };
2630
+ const scheduleUpdateRetry = () => {
2631
+ if (guardStopping || updateRetryTimeout) {
2632
+ return;
2633
+ }
2634
+ updateRetryTimeout = setTimeout(() => {
2635
+ updateRetryTimeout = undefined;
2636
+ void runUpdateCheck();
2637
+ }, UPDATE_RETRY_DELAY_MS);
2638
+ updateRetryTimeout.unref?.();
2639
+ };
2349
2640
  // Run first check after a short delay, then on interval
2350
2641
  if (canAutoUpdate) {
2351
2642
  updateCheckTimeout = setTimeout(runUpdateCheck, 30_000);
@@ -0,0 +1,3 @@
1
+ import type { CommandModule } from "yargs";
2
+ import type { ProxyAnalyzeArgs } from "../../lib/types/index.js";
3
+ export declare const proxyAnalyzeCommand: CommandModule<object, ProxyAnalyzeArgs>;