@juspay/neurolink 9.93.0 → 9.93.2

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 (53) 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 +3 -2
  4. package/dist/cli/commands/proxy.js +224 -66
  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/cli/utils/serverUtils.js +4 -1
  9. package/dist/lib/proxy/globalInstaller.js +1 -1
  10. package/dist/lib/proxy/openaiFormat.js +1 -0
  11. package/dist/lib/proxy/proxyAnalysis.d.ts +3 -0
  12. package/dist/lib/proxy/proxyAnalysis.js +454 -0
  13. package/dist/lib/proxy/proxyHealth.d.ts +4 -0
  14. package/dist/lib/proxy/proxyHealth.js +21 -0
  15. package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
  16. package/dist/lib/proxy/sseInterceptor.js +7 -6
  17. package/dist/lib/proxy/streamOutcome.d.ts +3 -1
  18. package/dist/lib/proxy/streamOutcome.js +77 -0
  19. package/dist/lib/proxy/updateCoordinator.d.ts +7 -0
  20. package/dist/lib/proxy/updateCoordinator.js +60 -0
  21. package/dist/lib/proxy/updateState.d.ts +4 -0
  22. package/dist/lib/proxy/updateState.js +51 -0
  23. package/dist/lib/proxy/workerLog.d.ts +6 -0
  24. package/dist/lib/proxy/workerLog.js +42 -0
  25. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
  26. package/dist/lib/server/routes/claudeProxyRoutes.js +114 -17
  27. package/dist/lib/server/routes/openaiProxyRoutes.d.ts +9 -0
  28. package/dist/lib/server/routes/openaiProxyRoutes.js +16 -1
  29. package/dist/lib/types/cli.d.ts +7 -0
  30. package/dist/lib/types/proxy.d.ts +187 -0
  31. package/dist/proxy/globalInstaller.js +1 -1
  32. package/dist/proxy/openaiFormat.js +1 -0
  33. package/dist/proxy/proxyAnalysis.d.ts +3 -0
  34. package/dist/proxy/proxyAnalysis.js +453 -0
  35. package/dist/proxy/proxyHealth.d.ts +4 -0
  36. package/dist/proxy/proxyHealth.js +21 -0
  37. package/dist/proxy/sseInterceptor.d.ts +9 -1
  38. package/dist/proxy/sseInterceptor.js +7 -6
  39. package/dist/proxy/streamOutcome.d.ts +3 -1
  40. package/dist/proxy/streamOutcome.js +77 -0
  41. package/dist/proxy/updateCoordinator.d.ts +7 -0
  42. package/dist/proxy/updateCoordinator.js +59 -0
  43. package/dist/proxy/updateState.d.ts +4 -0
  44. package/dist/proxy/updateState.js +51 -0
  45. package/dist/proxy/workerLog.d.ts +6 -0
  46. package/dist/proxy/workerLog.js +41 -0
  47. package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
  48. package/dist/server/routes/claudeProxyRoutes.js +114 -17
  49. package/dist/server/routes/openaiProxyRoutes.d.ts +9 -0
  50. package/dist/server/routes/openaiProxyRoutes.js +16 -1
  51. package/dist/types/cli.d.ts +7 -0
  52. package/dist/types/proxy.d.ts +187 -0
  53. package/package.json +4 -1
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import type { CommandModule } from "yargs";
13
13
  import type { Hono } from "hono";
14
- import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs } from "../../lib/types/index.js";
14
+ import type { AccountAllowlist, LoadedProxyConfig, ModelRouterInterface, ProxyGuardArgs, ProxyNeurolinkRuntime, ProxyStartArgs, ProxyStartStrategy, ProxyStatusArgs, ProxyTelemetryArgs, ProxyReadinessState } from "../../lib/types/index.js";
15
15
  import { ProxyRuntimeConfigStore } from "../../lib/proxy/runtimeConfig.js";
16
16
  export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
17
17
  export declare function createProxyStartApp(params: {
@@ -25,9 +25,10 @@ export declare function createProxyStartApp(params: {
25
25
  primaryAccountKey: string | undefined;
26
26
  accountAllowlist: AccountAllowlist | undefined;
27
27
  runtimeConfigStore?: ProxyRuntimeConfigStore;
28
+ updateControlToken?: string;
28
29
  }): Promise<{
29
30
  app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
30
- readiness: import("../../lib/types/proxy.js").ProxyReadinessState;
31
+ readiness: ProxyReadinessState;
31
32
  }>;
32
33
  export declare const proxyStartCommand: CommandModule<object, ProxyStartArgs>;
33
34
  export declare const proxyStatusCommand: CommandModule<object, ProxyStatusArgs>;
@@ -14,8 +14,9 @@ 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 { sanitizeForLog } from "../../lib/utils/logSanitize.js";
19
20
  import { withTimeout } from "../../lib/utils/async/withTimeout.js";
20
21
  import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serverUtils.js";
21
22
  import { configureProxyKeepAliveDispatcher } from "../../lib/proxy/proxyDispatcher.js";
@@ -25,7 +26,9 @@ import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from
25
26
  import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../lib/proxy/proxyLifecycle.js";
26
27
  import { describeInstallFailure, getGlobalInstallArgs, resolveGlobalInstaller, validateInstalledVersion, } from "../../lib/proxy/globalInstaller.js";
27
28
  import { startUpdaterWorkerSupervisor } from "../../lib/proxy/updaterSupervisor.js";
28
- import { abandonPendingUpdate, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
29
+ import { openProxyWorkerLog } from "../../lib/proxy/workerLog.js";
30
+ import { waitForProxyUpdateWindow } from "../../lib/proxy/updateCoordinator.js";
31
+ import { abandonPendingUpdate, clearUpdateDeferral, isVersionSuppressed, loadUpdateState, recordCheck, recordSuccessfulUpdate, recordUpdateDeferred, recordUpdateFailure, recordUpdateInstalled, suppressVersion, } from "../../lib/proxy/updateState.js";
29
32
  import { loadProxyEnvFile, resolveProxyEnvFile, } from "../../lib/proxy/proxyEnv.js";
30
33
  import { createRequire } from "node:module";
31
34
  import { fileURLToPath } from "node:url";
@@ -34,6 +37,8 @@ const _require = createRequire(import.meta.url);
34
37
  const PROXY_VERSION = packageJson.version;
35
38
  const PROXY_TELEMETRY_SCRIPT_PATH = fileURLToPath(new URL("../../../scripts/observability/manage-local-openobserve.sh", import.meta.url));
36
39
  const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
40
+ const PROXY_UPDATE_CONTROL_TOKEN = process.env.NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN?.trim() ||
41
+ crypto.randomUUID();
37
42
  // =============================================================================
38
43
  // STATE MANAGEMENT
39
44
  // =============================================================================
@@ -370,16 +375,52 @@ async function getProxyRuntimeActivity(host, port, timeoutMs = 3_000) {
370
375
  return null;
371
376
  }
372
377
  }
373
- function isSafeUpdateWindow(activity, quietThresholdMs, nowMs = Date.now()) {
374
- if (activity.activeRequests > 0) {
375
- return false;
378
+ async function setProxyUpdateDrain(host, port, draining) {
379
+ const confirmState = async () => {
380
+ try {
381
+ const response = await fetch(`http://${host}:${port}/status`, {
382
+ signal: AbortSignal.timeout(3_000),
383
+ });
384
+ if (!response.ok) {
385
+ return false;
386
+ }
387
+ const payload = (await response.json());
388
+ return payload.health?.drainingForUpdate === draining;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ };
394
+ try {
395
+ const response = await fetch(`http://${host}:${port}/internal/update-control`, {
396
+ method: "POST",
397
+ headers: {
398
+ "content-type": "application/json",
399
+ "x-neurolink-update-token": PROXY_UPDATE_CONTROL_TOKEN,
400
+ },
401
+ body: JSON.stringify({ action: draining ? "drain" : "resume" }),
402
+ signal: AbortSignal.timeout(5_000),
403
+ });
404
+ if (!response.ok) {
405
+ return confirmState();
406
+ }
407
+ const payload = (await response.json());
408
+ return payload.draining === draining;
409
+ }
410
+ catch {
411
+ return confirmState();
376
412
  }
377
- if (!activity.lastActivityAt) {
378
- return true;
413
+ }
414
+ async function resumeProxyUpdateDrain(host, port, attempts = 3) {
415
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
416
+ if (await setProxyUpdateDrain(host, port, false)) {
417
+ return true;
418
+ }
419
+ if (attempt < attempts) {
420
+ await sleep(1_000);
421
+ }
379
422
  }
380
- const lastActivityMs = Date.parse(activity.lastActivityAt);
381
- return (Number.isFinite(lastActivityMs) &&
382
- nowMs - lastActivityMs >= quietThresholdMs);
423
+ return false;
383
424
  }
384
425
  // ---------------------------------------------------------------------------
385
426
  // Stable entrypoint for launchd
@@ -499,18 +540,14 @@ function spawnFailOpenGuard(host, port, parentPid) {
499
540
  String(parentPid),
500
541
  "--quiet",
501
542
  ];
502
- // Write guard stdout/stderr to a log file instead of discarding them.
503
- const { openSync, closeSync, mkdirSync, existsSync } = _require("fs");
504
- const guardLogDir = join(homedir(), ".neurolink", "logs");
505
- if (!existsSync(guardLogDir)) {
506
- mkdirSync(guardLogDir, { recursive: true });
507
- }
508
- const guardLogPath = join(guardLogDir, "proxy-guard.log");
509
- const logFd = openSync(guardLogPath, "a");
543
+ const workerLog = openProxyWorkerLog("proxy-guard.log");
544
+ if (workerLog.error) {
545
+ logger.debug(`[proxy] guard logging disabled: ${workerLog.error}`);
546
+ }
510
547
  try {
511
548
  const child = spawn(process.execPath, args, {
512
549
  detached: true,
513
- stdio: ["ignore", logFd, logFd],
550
+ stdio: ["ignore", workerLog.stdio, workerLog.stdio],
514
551
  });
515
552
  child.unref();
516
553
  return child.pid;
@@ -520,7 +557,7 @@ function spawnFailOpenGuard(host, port, parentPid) {
520
557
  return undefined;
521
558
  }
522
559
  finally {
523
- closeSync(logFd); // parent closes its copy; child keeps the fd
560
+ workerLog.close();
524
561
  }
525
562
  }
526
563
  function spawnProxyUpdater(host, port, parentPid) {
@@ -546,17 +583,18 @@ function spawnProxyUpdater(host, port, parentPid) {
546
583
  "--updater-only",
547
584
  "--quiet",
548
585
  ];
549
- const { openSync, closeSync, mkdirSync, existsSync } = _require("fs");
550
- const logsDir = join(homedir(), ".neurolink", "logs");
551
- if (!existsSync(logsDir)) {
552
- mkdirSync(logsDir, { recursive: true });
586
+ const workerLog = openProxyWorkerLog("proxy-updater.log");
587
+ if (workerLog.error) {
588
+ logger.always(`[proxy] updater logging disabled: ${workerLog.error}`);
553
589
  }
554
- const logFd = openSync(join(logsDir, "proxy-updater.log"), "a");
555
590
  try {
556
591
  const child = spawn(process.execPath, args, {
557
592
  detached: true,
558
- stdio: ["ignore", logFd, logFd],
559
- env: process.env,
593
+ stdio: ["ignore", workerLog.stdio, workerLog.stdio],
594
+ env: {
595
+ ...process.env,
596
+ NEUROLINK_PROXY_UPDATE_CONTROL_TOKEN: PROXY_UPDATE_CONTROL_TOKEN,
597
+ },
560
598
  });
561
599
  child.once("error", (error) => {
562
600
  logger.always(`[proxy] updater worker error pid=${child.pid ?? "unknown"}: ${error.message}`);
@@ -572,7 +610,7 @@ function spawnProxyUpdater(host, port, parentPid) {
572
610
  return undefined;
573
611
  }
574
612
  finally {
575
- closeSync(logFd);
613
+ workerLog.close();
576
614
  }
577
615
  }
578
616
  async function runProxyTelemetryManager(command) {
@@ -727,7 +765,7 @@ async function createProxyNeurolinkRuntime(logsDir) {
727
765
  cleanupLogs(7, 500);
728
766
  return { neurolink, cleanupLogs };
729
767
  }
730
- function registerProxyRequestTracking(app, requestMetadata) {
768
+ function registerProxyRequestTracking(app, requestMetadata, readiness) {
731
769
  app.use("/v1/*", async (c, next) => {
732
770
  const startedMonotonicMs = performance.now();
733
771
  const contentLengthHeader = c.req.raw.headers.get("content-length");
@@ -747,9 +785,12 @@ function registerProxyRequestTracking(app, requestMetadata) {
747
785
  model: "-",
748
786
  stream: false,
749
787
  toolCount: 0,
788
+ rejectForUpdate: readiness.drainingForUpdate,
750
789
  };
751
790
  requestMetadata.set(c.req.raw, metadata);
752
- const finishActivity = beginProxyRequest();
791
+ const finishActivity = metadata.rejectForUpdate
792
+ ? () => undefined
793
+ : beginProxyRequest();
753
794
  const finish = () => {
754
795
  finishActivity();
755
796
  requestMetadata.delete(c.req.raw);
@@ -924,7 +965,34 @@ export async function createProxyStartApp(params) {
924
965
  },
925
966
  }, 502);
926
967
  });
927
- registerProxyRequestTracking(app, requestMetadata);
968
+ app.post("/internal/update-control", async (c) => {
969
+ const suppliedToken = c.req.header("x-neurolink-update-token");
970
+ const expectedToken = params.updateControlToken ?? PROXY_UPDATE_CONTROL_TOKEN;
971
+ if (!suppliedToken || suppliedToken !== expectedToken) {
972
+ return c.json({ error: "not_found" }, 404);
973
+ }
974
+ const payload = await c.req
975
+ .json()
976
+ .catch(() => ({ action: undefined }));
977
+ if (payload.action === "drain") {
978
+ if (!markProxyDrainingForUpdate(readiness)) {
979
+ return c.json({ error: "proxy_not_ready" }, 409);
980
+ }
981
+ }
982
+ else if (payload.action === "resume") {
983
+ if (!resumeProxyConnections(readiness)) {
984
+ return c.json({ error: "proxy_not_ready" }, 409);
985
+ }
986
+ }
987
+ else {
988
+ return c.json({ error: "invalid_action" }, 400);
989
+ }
990
+ return c.json({
991
+ draining: readiness.drainingForUpdate,
992
+ acceptingConnections: readiness.acceptingConnections,
993
+ });
994
+ });
995
+ registerProxyRequestTracking(app, requestMetadata, readiness);
928
996
  const runtimeConfigStore = params.runtimeConfigStore;
929
997
  const runtimeConfigProvider = runtimeConfigStore
930
998
  ? () => runtimeConfigStore.getSnapshot()
@@ -940,6 +1008,24 @@ export async function createProxyStartApp(params) {
940
1008
  for (const route of allProxyRoutes) {
941
1009
  const method = route.method.toLowerCase();
942
1010
  app[method](route.path, async (c) => {
1011
+ const metadata = requestMetadata.get(c.req.raw);
1012
+ if (metadata?.rejectForUpdate) {
1013
+ if (metadata) {
1014
+ metadata.terminalErrorType = "proxy_draining";
1015
+ await recordRuntimeError(metadata, 503, "proxy_draining", "Proxy is draining for an automatic update", {
1016
+ clientMessage: "Proxy is restarting; retry shortly",
1017
+ clientErrorType: "overloaded_error",
1018
+ });
1019
+ }
1020
+ c.header("Retry-After", "2");
1021
+ return c.json({
1022
+ type: "error",
1023
+ error: {
1024
+ type: "overloaded_error",
1025
+ message: "Proxy is restarting; retry shortly",
1026
+ },
1027
+ }, 503);
1028
+ }
943
1029
  const emptyBody = {};
944
1030
  let body;
945
1031
  let rawBody;
@@ -970,13 +1056,13 @@ export async function createProxyStartApp(params) {
970
1056
  const toolCount = Array.isArray(bodyRec?.tools)
971
1057
  ? bodyRec.tools.length
972
1058
  : 0;
973
- const metadata = requestMetadata.get(c.req.raw);
974
1059
  if (metadata) {
975
1060
  metadata.model = String(model);
976
1061
  metadata.stream = stream === "stream";
977
1062
  metadata.toolCount = toolCount;
978
1063
  }
979
- logger.always(`[proxy] ${c.req.method} ${c.req.path} model=${model} ${stream} tools=${toolCount}`);
1064
+ const logModel = sanitizeForLog(String(model));
1065
+ logger.always(`[proxy] ${c.req.method} ${c.req.path} → model=${logModel} ${stream} tools=${toolCount}`);
980
1066
  const ctx = {
981
1067
  requestId: metadata?.requestId ?? crypto.randomUUID(),
982
1068
  method: c.req.method,
@@ -1175,6 +1261,7 @@ export async function createProxyStartApp(params) {
1175
1261
  liveVersion: PROXY_VERSION,
1176
1262
  latestVersion: updateState?.lastCheckVersion || null,
1177
1263
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
1264
+ deferredUpdate: updateState?.deferredUpdate ?? null,
1178
1265
  lastCheckAt: updateState?.lastCheckAt ?? null,
1179
1266
  lastUpdateAt: updateState?.lastUpdateAt ?? null,
1180
1267
  lastUpdateVersion: updateState?.lastUpdateVersion ?? null,
@@ -1978,6 +2065,7 @@ export const proxyStatusCommand = {
1978
2065
  updaterRunning: false,
1979
2066
  latestVersion: updateState?.lastCheckVersion || null,
1980
2067
  pendingRestartVersion: updateState?.pendingRestartVersion ?? null,
2068
+ deferredUpdate: updateState?.deferredUpdate ?? null,
1981
2069
  lastUpdateFailure: updateState?.lastFailure ?? null,
1982
2070
  };
1983
2071
  if (state && isProcessRunning(state.pid)) {
@@ -2057,6 +2145,12 @@ export const proxyStatusCommand = {
2057
2145
  if (status.pendingRestartVersion) {
2058
2146
  logger.always(` ${chalk.bold("Pending:")} ${chalk.yellow(`v${status.pendingRestartVersion} installed; restart pending`)}`);
2059
2147
  }
2148
+ if (status.deferredUpdate) {
2149
+ const active = status.deferredUpdate.activeRequests === null
2150
+ ? "unknown activity"
2151
+ : `${status.deferredUpdate.activeRequests} active request(s)`;
2152
+ logger.always(` ${chalk.bold("Deferred:")} ${chalk.yellow(`v${status.deferredUpdate.version} ${status.deferredUpdate.reason} (${active})`)}`);
2153
+ }
2060
2154
  if (status.latestVersion) {
2061
2155
  logger.always(` ${chalk.bold("Latest:")} ${chalk.cyan(`v${status.latestVersion}`)}`);
2062
2156
  }
@@ -2239,6 +2333,10 @@ export const proxyGuardCommand = {
2239
2333
  // foreground fail-open guard remains cleanup-only.
2240
2334
  const UPDATE_CHECK_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours
2241
2335
  const QUIET_THRESHOLD_MS = 120 * 1000; // 2 minutes of silence
2336
+ const NATURAL_WINDOW_WAIT_MS = 10 * 60 * 1000; // prefer no admission pause
2337
+ const UPDATE_DRAIN_TIMEOUT_MS = 30 * 60 * 1000; // preserve long streams
2338
+ const UPDATE_RETRY_DELAY_MS = 5 * 60 * 1000;
2339
+ const UPDATE_ACTIVITY_POLL_MS = 10 * 1000;
2242
2340
  const UPDATE_TIMEOUT_MS = 30 * 1000; // 30 seconds to come healthy
2243
2341
  // Get running version from /health endpoint (with timeout to avoid hanging)
2244
2342
  let runningVersion = PROXY_VERSION; // fallback
@@ -2261,6 +2359,7 @@ export const proxyGuardCommand = {
2261
2359
  let guardStopping = false;
2262
2360
  let updateCheckTimeout;
2263
2361
  let updateCheckInterval;
2362
+ let updateRetryTimeout;
2264
2363
  const stopUpdateChecks = () => {
2265
2364
  guardStopping = true;
2266
2365
  if (updateCheckTimeout) {
@@ -2271,6 +2370,10 @@ export const proxyGuardCommand = {
2271
2370
  clearInterval(updateCheckInterval);
2272
2371
  updateCheckInterval = undefined;
2273
2372
  }
2373
+ if (updateRetryTimeout) {
2374
+ clearTimeout(updateRetryTimeout);
2375
+ updateRetryTimeout = undefined;
2376
+ }
2274
2377
  };
2275
2378
  /** Keep state-write failures observable without terminating the updater. */
2276
2379
  const persistUpdaterState = (operation, action) => {
@@ -2289,41 +2392,93 @@ export const proxyGuardCommand = {
2289
2392
  }
2290
2393
  updateInProgress = true;
2291
2394
  let updateVersion = runningVersion;
2395
+ let drainActive = false;
2292
2396
  try {
2293
2397
  // Lazy-load update modules so they're only imported at check time
2294
2398
  const { checkForUpdate } = await import("../../lib/proxy/updateChecker.js");
2295
2399
  // 1. Check for update
2296
- const result = await checkForUpdate(runningVersion);
2400
+ let result = await checkForUpdate(runningVersion);
2297
2401
  updateVersion = result.latestVersion;
2298
2402
  persistUpdaterState("record update check", () => recordCheck(result.latestVersion));
2299
2403
  if (!result.updateAvailable) {
2404
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2300
2405
  return;
2301
2406
  }
2302
- const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2303
- if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
2407
+ const initiallyPendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2408
+ if (isVersionSuppressed(result.latestVersion) &&
2409
+ !initiallyPendingRestart) {
2304
2410
  logger.debug(`[guard] version ${result.latestVersion} is suppressed, skipping`);
2305
2411
  return;
2306
2412
  }
2307
2413
  logger.always(`[updater] update available: ${runningVersion} → ${result.latestVersion}`);
2308
- // 2. Wait for an exact runtime-idle window. Never force an update while
2309
- // a request or stream is active; the next worker cycle can try again.
2310
- const quietPollMs = 10_000; // check every 10s
2311
- while (!guardStopping) {
2312
- if (getProcessStatus(parentPid) === "not_running") {
2313
- logger.always(`[updater] parent process died while waiting for idle traffic`);
2314
- return;
2414
+ // 2. Prefer a naturally quiet period. Sustained traffic is handled by
2415
+ // a bounded graceful drain so update checks cannot wait forever.
2416
+ let lastDeferralSignature = "";
2417
+ const waitForWindow = async (quietWaitMs) => {
2418
+ const window = await waitForProxyUpdateWindow({
2419
+ quietThresholdMs: QUIET_THRESHOLD_MS,
2420
+ quietWaitMs,
2421
+ drainWaitMs: UPDATE_DRAIN_TIMEOUT_MS,
2422
+ pollIntervalMs: UPDATE_ACTIVITY_POLL_MS,
2423
+ getActivity: () => getProxyRuntimeActivity(host, port),
2424
+ setDraining: async (draining) => {
2425
+ const changed = await setProxyUpdateDrain(host, port, draining);
2426
+ if (changed) {
2427
+ logger.always(`[updater] ${draining ? "draining new inference requests" : "inference admission resumed"}`);
2428
+ }
2429
+ return changed;
2430
+ },
2431
+ isStopping: () => guardStopping,
2432
+ isParentAlive: () => getProcessStatus(parentPid) !== "not_running",
2433
+ onPhase: (phase, activity) => {
2434
+ const reason = phase === "waiting_for_quiet" && !activity
2435
+ ? "activity_unavailable"
2436
+ : phase;
2437
+ const signature = `${reason}:${activity?.activeRequests ?? "unknown"}`;
2438
+ if (signature !== lastDeferralSignature) {
2439
+ lastDeferralSignature = signature;
2440
+ persistUpdaterState("record update deferral", () => recordUpdateDeferred(result.latestVersion, reason, activity?.activeRequests ?? null));
2441
+ }
2442
+ logger.debug(`[updater] ${reason} (${activity?.activeRequests ?? "unknown"} active requests)`);
2443
+ },
2444
+ });
2445
+ drainActive = drainActive || window.draining;
2446
+ return window;
2447
+ };
2448
+ let updateWindow = await waitForWindow(NATURAL_WINDOW_WAIT_MS);
2449
+ if (!updateWindow.ready) {
2450
+ if (updateWindow.reason === "drain_failed") {
2451
+ persistUpdaterState("record unavailable update drain", () => recordUpdateDeferred(result.latestVersion, "drain_unavailable", null));
2315
2452
  }
2316
- const activity = await getProxyRuntimeActivity(host, port);
2317
- if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2318
- logger.always(`[updater] traffic idle, proceeding with update`);
2319
- break;
2453
+ scheduleUpdateRetry();
2454
+ return;
2455
+ }
2456
+ // Close the small race between observing zero activity and package
2457
+ // mutation by holding admission closed through install and restart.
2458
+ if (!drainActive) {
2459
+ updateWindow = await waitForWindow(0);
2460
+ if (!updateWindow.ready) {
2461
+ scheduleUpdateRetry();
2462
+ return;
2320
2463
  }
2321
- logger.debug(`[updater] waiting for idle traffic (${activity?.activeRequests ?? "unknown"} active requests)`);
2322
- await new Promise((r) => setTimeout(r, quietPollMs));
2323
2464
  }
2324
- if (guardStopping) {
2465
+ // Refresh after waiting so a long deferral can never install a stale
2466
+ // target while a newer release is already available.
2467
+ const refreshedResult = await checkForUpdate(runningVersion);
2468
+ result = refreshedResult;
2469
+ updateVersion = result.latestVersion;
2470
+ persistUpdaterState("record refreshed update check", () => recordCheck(result.latestVersion));
2471
+ if (!result.updateAvailable) {
2472
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2325
2473
  return;
2326
2474
  }
2475
+ const pendingRestart = loadUpdateState()?.pendingRestartVersion === result.latestVersion;
2476
+ if (isVersionSuppressed(result.latestVersion) && !pendingRestart) {
2477
+ logger.debug(`[guard] refreshed version ${result.latestVersion} is suppressed, skipping`);
2478
+ return;
2479
+ }
2480
+ persistUpdaterState("clear update deferral", () => clearUpdateDeferral());
2481
+ logger.always(`[updater] safe update window acquired for v${result.latestVersion}`);
2327
2482
  // 3. Install update (validate version string before passing to shell)
2328
2483
  if (!/^\d+\.\d+\.\d+$/.test(result.latestVersion)) {
2329
2484
  const message = `invalid version format: ${result.latestVersion}`;
@@ -2396,22 +2551,8 @@ export const proxyGuardCommand = {
2396
2551
  if (guardStopping || getProcessStatus(parentPid) === "not_running") {
2397
2552
  return;
2398
2553
  }
2399
- // Installation can overlap new traffic. Re-establish a full idle window
2400
- // before restart so no accepted request or long stream is interrupted.
2401
- while (!guardStopping) {
2402
- if (getProcessStatus(parentPid) === "not_running") {
2403
- return;
2404
- }
2405
- const activity = await getProxyRuntimeActivity(host, port);
2406
- if (activity && isSafeUpdateWindow(activity, QUIET_THRESHOLD_MS)) {
2407
- break;
2408
- }
2409
- logger.debug(`[updater] update installed; deferring restart (${activity?.activeRequests ?? "unknown"} active requests)`);
2410
- await sleep(10_000);
2411
- }
2412
- if (guardStopping) {
2413
- return;
2414
- }
2554
+ // Admission has remained closed since active requests reached zero, so
2555
+ // restart cannot interrupt an accepted request or long-lived stream.
2415
2556
  // Signal the health loop to not exit when it detects
2416
2557
  // the parent PID is gone — we're intentionally restarting.
2417
2558
  updateRestartInProgress = true;
@@ -2432,6 +2573,7 @@ export const proxyGuardCommand = {
2432
2573
  persistUpdaterState("record update failure", () => recordUpdateFailure(result.latestVersion, "restart", msg));
2433
2574
  return;
2434
2575
  }
2576
+ drainActive = false;
2435
2577
  // 5. Wait for healthy restart
2436
2578
  let healthy = false;
2437
2579
  const restartStart = Date.now();
@@ -2473,9 +2615,25 @@ export const proxyGuardCommand = {
2473
2615
  persistUpdaterState("record update failure", () => recordUpdateFailure(updateVersion, "check", message));
2474
2616
  }
2475
2617
  finally {
2618
+ if (drainActive && !updateRestartInProgress) {
2619
+ const resumed = await resumeProxyUpdateDrain(host, port);
2620
+ if (!resumed && getProcessStatus(parentPid) !== "not_running") {
2621
+ logger.always(`[updater] WARNING: failed to resume inference admission after deferred update`);
2622
+ }
2623
+ }
2476
2624
  updateInProgress = false;
2477
2625
  }
2478
2626
  };
2627
+ const scheduleUpdateRetry = () => {
2628
+ if (guardStopping || updateRetryTimeout) {
2629
+ return;
2630
+ }
2631
+ updateRetryTimeout = setTimeout(() => {
2632
+ updateRetryTimeout = undefined;
2633
+ void runUpdateCheck();
2634
+ }, UPDATE_RETRY_DELAY_MS);
2635
+ updateRetryTimeout.unref?.();
2636
+ };
2479
2637
  // Run first check after a short delay, then on interval
2480
2638
  if (canAutoUpdate) {
2481
2639
  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>;
@@ -0,0 +1,147 @@
1
+ import chalk from "chalk";
2
+ import { analyzeProxyLogs } from "../../lib/proxy/proxyAnalysis.js";
3
+ import { logger } from "../../lib/utils/logger.js";
4
+ function formatLatency(label, summary) {
5
+ const value = (amount) => amount === null ? "-" : amount.toFixed(1);
6
+ return `${label.padEnd(22)} ${String(summary.count).padStart(7)} ${value(summary.p50).padStart(9)} ${value(summary.p95).padStart(9)} ${value(summary.p99).padStart(9)} ${value(summary.max).padStart(9)}`;
7
+ }
8
+ function printAnalysis(report) {
9
+ logger.always("");
10
+ logger.always(chalk.bold.cyan("NeuroLink Proxy Analysis"));
11
+ logger.always(chalk.gray("=".repeat(50)));
12
+ logger.always(` Since: ${chalk.cyan(report.since)}`);
13
+ logger.always(` Logs: ${chalk.cyan(report.logsDir)}`);
14
+ logger.always(` Files: ${report.files.requests} request, ${report.files.attempts} attempt, ${report.files.lifecycle} lifecycle`);
15
+ logger.always("");
16
+ logger.always(chalk.bold(" Reliability"));
17
+ logger.always(report.coverage.finalRequests
18
+ ? ` Completed: ${report.requests.completed} (${chalk.green(`${report.requests.success} success`)}, ${chalk.red(`${report.requests.errors} errors`)})`
19
+ : chalk.yellow(" Completed: unavailable (no final request logs)"));
20
+ logger.always(report.coverage.attempts && report.coverage.finalRequests
21
+ ? ` Recovered after retry: ${report.requests.recoveredAfterRetry}`
22
+ : chalk.yellow(" Recovered after retry: unavailable"));
23
+ if (report.requests.errors > 0) {
24
+ logger.always(` Final error types: ${JSON.stringify(report.requests.errorTypes)}`);
25
+ }
26
+ if (report.coverage.attempts) {
27
+ logger.always(` Attempts: ${report.attempts.total}, ${report.attempts.errors} errors${report.attempts.errors > 0 ? ` ${JSON.stringify(report.attempts.errorTypes)}` : ""}`);
28
+ }
29
+ logger.always(report.coverage.lifecycle
30
+ ? ` Lifecycle: ${report.lifecycle.accepted} accepted, ${report.lifecycle.terminal} terminal, ${report.lifecycle.unsettled} unsettled`
31
+ : chalk.yellow(" Lifecycle: unavailable (no lifecycle metadata)"));
32
+ if (report.coverage.attempts) {
33
+ const finalRateLimits = report.coverage.finalRequests
34
+ ? `${report.requests.finalRateLimits} final`
35
+ : "final unavailable";
36
+ logger.always(` Rate limits: ${report.rateLimits.attemptRateLimits} attempts (${report.rateLimits.quota} quota, ${report.rateLimits.transient} transient, ${report.rateLimits.unclassified} unclassified), ${finalRateLimits}`);
37
+ }
38
+ else if (report.coverage.finalRequests) {
39
+ logger.always(chalk.yellow(` Rate limits: attempt classification unavailable; ${report.requests.finalRateLimits} final 429 response(s)`));
40
+ }
41
+ else {
42
+ logger.always(chalk.yellow(" Rate limits: unavailable"));
43
+ }
44
+ logger.always("");
45
+ logger.always(chalk.bold(" Latency (ms)"));
46
+ const latencyHeader = `${"METRIC".padEnd(22)} ${"COUNT".padStart(7)} ${"P50".padStart(9)} ${"P95".padStart(9)} ${"P99".padStart(9)} ${"MAX".padStart(9)}`;
47
+ logger.always(` ${chalk.gray(latencyHeader)}`);
48
+ logger.always(` ${chalk.gray("-".repeat(latencyHeader.length))}`);
49
+ for (const [label, summary] of [
50
+ ["Response headers", report.latencyMs.headers],
51
+ ["First chunk", report.latencyMs.firstChunk],
52
+ ["Terminal", report.latencyMs.terminal],
53
+ ["Final request log", report.latencyMs.finalRequest],
54
+ ["Account attempt", report.latencyMs.attempt],
55
+ ["Single-attempt delta", report.latencyMs.singleAttemptDelta],
56
+ ]) {
57
+ logger.always(` ${formatLatency(label, summary)}`);
58
+ }
59
+ logger.always("");
60
+ logger.always(chalk.bold(" Cache"));
61
+ if (report.coverage.cacheUsage) {
62
+ logger.always(` Usage records: ${report.cache.requestsWithUsage}, cache-read requests: ${report.cache.requestsWithCacheRead}, hit rate: ${report.cache.requestHitRate === null ? "-" : `${(report.cache.requestHitRate * 100).toFixed(1)}%`}`);
63
+ logger.always(` Tokens: ${report.cache.cacheReadTokens} read, ${report.cache.cacheCreationTokens} created, ${report.cache.inputTokens} input`);
64
+ }
65
+ else {
66
+ logger.always(chalk.yellow(" Cache usage: unavailable"));
67
+ }
68
+ logger.always("");
69
+ logger.always(chalk.bold(" Data Quality"));
70
+ logger.always(` ${report.dataQuality.linesRead} lines scanned, ${report.dataQuality.malformedLines} malformed, ${report.dataQuality.unsupportedLifecycleLines} unsupported lifecycle, ${report.dataQuality.lifecycleSequenceGaps} sequence gaps, ${report.dataQuality.lifecycleSequenceDuplicates} duplicates`);
71
+ if (report.accounts.length > 0) {
72
+ logger.always("");
73
+ logger.always(chalk.bold(" Accounts"));
74
+ const header = `${"ACCOUNT".padEnd(30)} ${"AUTH".padEnd(8)} ${"ATTEMPT".padStart(8)} ${"ATT ERR".padStart(7)} ${"FINAL".padStart(7)} ${"FIN ERR".padStart(7)} ${"QUOTA".padStart(7)} ${"TRANS".padStart(7)} ${"UNKNOWN".padStart(8)}`;
75
+ logger.always(` ${chalk.gray(header)}`);
76
+ logger.always(` ${chalk.gray("-".repeat(header.length))}`);
77
+ for (const account of report.accounts) {
78
+ const attempt = report.coverage.attempts ? String(account.attempts) : "-";
79
+ const attemptErrors = report.coverage.attempts
80
+ ? String(account.attemptErrors)
81
+ : "-";
82
+ const quota = report.coverage.attempts
83
+ ? String(account.quotaRateLimits)
84
+ : "-";
85
+ const transient = report.coverage.attempts
86
+ ? String(account.transientRateLimits)
87
+ : "-";
88
+ const unknown = report.coverage.attempts
89
+ ? String(account.unclassifiedRateLimits)
90
+ : "-";
91
+ const finalRequests = report.coverage.finalRequests
92
+ ? String(account.finalRequests)
93
+ : "-";
94
+ const finalErrors = report.coverage.finalRequests
95
+ ? String(account.finalErrors)
96
+ : "-";
97
+ logger.always(` ${account.account.slice(0, 30).padEnd(30)} ${account.accountType.slice(0, 8).padEnd(8)} ${attempt.padStart(8)} ${attemptErrors.padStart(7)} ${finalRequests.padStart(7)} ${finalErrors.padStart(7)} ${quota.padStart(7)} ${transient.padStart(7)} ${unknown.padStart(8)}`);
98
+ }
99
+ }
100
+ logger.always("");
101
+ }
102
+ export const proxyAnalyzeCommand = {
103
+ command: "analyze",
104
+ describe: "Analyze local proxy reliability and latency logs",
105
+ builder: (yargs) => yargs
106
+ .option("logs-dir", {
107
+ type: "string",
108
+ alias: "logsDir",
109
+ description: "Proxy JSONL log directory",
110
+ })
111
+ .option("since", {
112
+ type: "string",
113
+ default: "24h",
114
+ description: "ISO timestamp or lookback such as 6h, 1d, or 1w",
115
+ })
116
+ .option("format", {
117
+ type: "string",
118
+ choices: ["text", "json"],
119
+ default: "text",
120
+ description: "Output format",
121
+ })
122
+ .option("quiet", {
123
+ type: "boolean",
124
+ alias: "q",
125
+ default: false,
126
+ description: "Suppress non-essential output",
127
+ })
128
+ .example("neurolink proxy analyze --since 24h", "Analyze the last 24 hours of proxy logs"),
129
+ handler: async (argv) => {
130
+ try {
131
+ const report = await analyzeProxyLogs({
132
+ logsDir: argv.logsDir,
133
+ since: argv.since,
134
+ });
135
+ if (argv.format === "json") {
136
+ logger.always(JSON.stringify(report, null, 2));
137
+ return;
138
+ }
139
+ printAnalysis(report);
140
+ }
141
+ catch (error) {
142
+ logger.error(chalk.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
143
+ process.exitCode = 1;
144
+ }
145
+ },
146
+ };
147
+ //# sourceMappingURL=proxyAnalyze.js.map