@lensmcp/cluster 1.16.5 → 1.16.8

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.
@@ -1,8 +1,9 @@
1
1
  import type { AuthVerdict, GatewayRuntime, GwTrace, Route } from './types';
2
2
  import type { Observability } from './observability';
3
+ import type { JwtVerifier } from '../jwks-verify';
3
4
  export interface EdgeAuth {
4
5
  internal(req: import('node:http').IncomingMessage, route: Route, trace: GwTrace | undefined): AuthVerdict;
5
6
  public(req: import('node:http').IncomingMessage, route: Route, trace: GwTrace | undefined): AuthVerdict;
6
7
  }
7
- export declare function createEdgeAuth(rt: GatewayRuntime, obs: Observability): EdgeAuth;
8
+ export declare function createEdgeAuth(rt: GatewayRuntime, obs: Observability, verifier?: JwtVerifier): EdgeAuth;
8
9
  //# sourceMappingURL=auth.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/auth.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,OAAO,WAAW,EAAE,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;IAC1G,MAAM,CAAC,GAAG,EAAE,OAAO,WAAW,EAAE,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;CACzG;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,QAAQ,CA8C/E"}
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/auth.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,WAAW,EAAE,cAAc,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAC3E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAElD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,OAAO,WAAW,EAAE,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;IAC1G,MAAM,CAAC,GAAG,EAAE,OAAO,WAAW,EAAE,eAAe,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;CACzG;AAED,wBAAgB,cAAc,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,CA8CvG"}
@@ -14,7 +14,7 @@ exports.createEdgeAuth = createEdgeAuth;
14
14
  */
15
15
  const manifest_1 = require("../manifest");
16
16
  const dev_auth_1 = require("./dev-auth");
17
- function createEdgeAuth(rt, obs) {
17
+ function createEdgeAuth(rt, obs, verifier) {
18
18
  const { traceStep } = obs;
19
19
  const internal = (req, _route, trace) => {
20
20
  // service-to-service: validate the CALLER's api key at the boundary
@@ -37,7 +37,7 @@ function createEdgeAuth(rt, obs) {
37
37
  const method = (req.method ?? 'GET').toUpperCase();
38
38
  const resolved = (0, manifest_1.authRuleFor)(policy, url0, method);
39
39
  if (resolved.mode === 'jwt') {
40
- const claims = (0, dev_auth_1.verifyDevJwt)(req);
40
+ const claims = (0, dev_auth_1.verifyDevJwt)(req, verifier);
41
41
  if (!claims) {
42
42
  traceStep(trace, 'auth', { mode: 'jwt', ok: false });
43
43
  return { ok: false, status: 401, reason: 'unauthorized', detail: 'Unauthorized: invalid or missing token' };
@@ -1,7 +1,14 @@
1
1
  import type * as http from 'node:http';
2
2
  import type { Attrs } from './types';
3
- /** Verify the end-user JWT and return its claims, or undefined. */
4
- export declare function verifyDevJwt(req: http.IncomingMessage): Record<string, unknown> | undefined;
3
+ import type { JwtVerifier } from '../jwks-verify';
4
+ /**
5
+ * Verify the end-user JWT and return its claims, or undefined.
6
+ *
7
+ * @param verifier when provided (the RS256/JWKS verifier wired by the composition root), it is the ONLY
8
+ * acceptor — the token is verified against the issuer's published keys by `kid`, so a rogue key / unknown
9
+ * kid / bad signature / `alg:none` / HS256-confusion / expired token all return undefined (fail closed).
10
+ */
11
+ export declare function verifyDevJwt(req: http.IncomingMessage, verifier?: JwtVerifier): Record<string, unknown> | undefined;
5
12
  /** Stamp verified identity onto the forwarded request — tenant from the `tid` CLAIM, never a client header. */
6
13
  export declare function stampIdentity(req: http.IncomingMessage, claims: Record<string, unknown>): void;
7
14
  /** Build the ABAC attribute bag from the verified claims + request envelope. */
@@ -1 +1 @@
1
- {"version":3,"file":"dev-auth.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/dev-auth.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,mEAAmE;AACnE,wBAAgB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAiC3F;AAED,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAQ9F;AAED,gFAAgF;AAChF,eAAO,MAAM,QAAQ,GAAI,KAAK,IAAI,CAAC,eAAe,EAAE,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,KAKpF,CAAC"}
1
+ {"version":3,"file":"dev-auth.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/dev-auth.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,KAAK,IAAI,MAAM,WAAW,CAAC;AACvC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AASlD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAyCnH;AAED,+GAA+G;AAC/G,wBAAgB,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,eAAe,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAQ9F;AAED,gFAAgF;AAChF,eAAO,MAAM,QAAQ,GAAI,KAAK,IAAI,CAAC,eAAe,EAAE,QAAQ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAG,KAKpF,CAAC"}
@@ -4,25 +4,45 @@ exports.devAttrs = void 0;
4
4
  exports.verifyDevJwt = verifyDevJwt;
5
5
  exports.stampIdentity = stampIdentity;
6
6
  /**
7
- * Dev edge auth (HS256), mirroring the prod gateway: verify the end-user token, then stamp the
8
- * trusted identity headers the services read (via GatewayTrustGuard). RS256/JWKS is prod's job;
9
- * dev shares the `AUTH_JWT_SECRET` the auth host signs with. Pure functions.
7
+ * Dev edge auth: verify the end-user token, then stamp the trusted identity headers the services read
8
+ * (via GatewayTrustGuard). It MIRRORS the prod gateway's posture — a real JWKS/RS256 verifier when one is
9
+ * wired (see `../jwks-verify` + the composition in `server.ts`), an HS256 shared-secret path as the legacy
10
+ * fallback. Pure functions (the verifier is injected, so unit tests supply their own JWKS).
11
+ *
12
+ * SECURITY (the invariant this file must hold): a `jwt` route MUST NOT accept a token whose signature the
13
+ * edge cannot verify. When a JWKS verifier is wired it is the SOLE acceptor — every alg goes through it, so
14
+ * an unknown `kid`, a bad signature, `alg:none`, an HS256-confusion token, and an expired token ALL fail
15
+ * closed (rejected). Without a verifier, an asymmetric (RS256/ES*) token cannot be checked (we don't hold
16
+ * the issuer's public keys), so it also fails closed — unless an operator EXPLICITLY opts into the old
17
+ * insecure decode-only path (`LENSMCP_GW_DEV_JWT_INSECURE_DECODE=1`) for local bring-up. The HS256
18
+ * shared-secret path stays for services using that convention (and is itself bypassed once JWKS is wired).
10
19
  */
11
20
  const node_crypto_1 = require("node:crypto");
12
- /** Verify the end-user JWT and return its claims, or undefined. */
13
- function verifyDevJwt(req) {
14
- const secret = process.env['AUTH_JWT_SECRET'] ?? 'tetros-dev-secret';
15
- const auth = String((Array.isArray(req.headers['authorization']) ? req.headers['authorization'][0] : req.headers['authorization']) ?? '');
16
- if (!auth.startsWith('Bearer '))
21
+ /** The compact JWS from the `Authorization: Bearer …` header, or undefined. */
22
+ function bearerToken(req) {
23
+ const raw = Array.isArray(req.headers['authorization']) ? req.headers['authorization'][0] : req.headers['authorization'];
24
+ const auth = String(raw ?? '');
25
+ return auth.startsWith('Bearer ') ? auth.slice(7) : undefined;
26
+ }
27
+ /**
28
+ * Verify the end-user JWT and return its claims, or undefined.
29
+ *
30
+ * @param verifier when provided (the RS256/JWKS verifier wired by the composition root), it is the ONLY
31
+ * acceptor — the token is verified against the issuer's published keys by `kid`, so a rogue key / unknown
32
+ * kid / bad signature / `alg:none` / HS256-confusion / expired token all return undefined (fail closed).
33
+ */
34
+ function verifyDevJwt(req, verifier) {
35
+ const token = bearerToken(req);
36
+ if (!token)
17
37
  return undefined;
18
- const [h, p, s] = auth.slice(7).split('.');
38
+ const [h, p, s] = token.split('.');
19
39
  if (!h || !p || !s)
20
40
  return undefined;
21
- // An IdP that signs ASYMMETRIC (RS256 + JWKS — e.g. tetros's auth host) does NOT use the HS256 dev
22
- // shared-secret this dev edge assumed. The PROD gateway verifies RS256 via jwks-verify; in dev we
23
- // DECODE + exp-check the claims (no signature check) — the gateway is the only local ingress, the token
24
- // came from the trusted local IdP, and a dev IdP's ephemeral per-pod keys make in-cluster JWKS
25
- // verification unreliable. (The HS256 path below stays for services using the shared-secret convention.)
41
+ // Preferred: a real JWKS/RS256 verifier (mirrors the prod gateway's `jwks ? jwks.verify(tok) : hs256`).
42
+ // When wired it is the SOLE acceptor, so every forgery vector below is closed at once.
43
+ if (verifier)
44
+ return verifier.verify(token);
45
+ // ── No verifier wired (legacy dev) ──────────────────────────────────────────────────────────────────
26
46
  let alg;
27
47
  try {
28
48
  alg = JSON.parse(Buffer.from(h, 'base64url').toString())['alg'];
@@ -31,6 +51,13 @@ function verifyDevJwt(req) {
31
51
  return undefined;
32
52
  }
33
53
  if (typeof alg === 'string' && alg !== 'HS256') {
54
+ // An asymmetric IdP (RS256 + JWKS) does NOT use the HS256 dev shared secret. Without the issuer's public
55
+ // keys we CANNOT verify the signature, so — unlike the old code, which decode-accepted any RS256 token
56
+ // (a full edge auth bypass: a rogue key or a forged `alg:none` sailed through) — we FAIL CLOSED. Wire a
57
+ // JWKS verifier (LENSMCP_GW_JWKS_URL, or the auto-derived auth-host JWKS) to accept RS256 tokens.
58
+ if (process.env['LENSMCP_GW_DEV_JWT_INSECURE_DECODE'] !== '1')
59
+ return undefined; // fail closed
60
+ // Explicit, dev-only opt-in to the legacy decode + exp-check (NO signature check) — bring-up escape hatch.
34
61
  try {
35
62
  const rsClaims = JSON.parse(Buffer.from(p, 'base64url').toString());
36
63
  if (typeof rsClaims['exp'] === 'number' && Date.now() / 1000 > rsClaims['exp'])
@@ -41,6 +68,8 @@ function verifyDevJwt(req) {
41
68
  return undefined;
42
69
  }
43
70
  }
71
+ // HS256 shared-secret convention (only reachable when NO JWKS verifier is wired).
72
+ const secret = process.env['AUTH_JWT_SECRET'] ?? 'tetros-dev-secret';
44
73
  const expected = (0, node_crypto_1.createHmac)('sha256', secret).update(`${h}.${p}`).digest('base64url');
45
74
  const a = Buffer.from(s);
46
75
  const b = Buffer.from(expected);
@@ -1 +1 @@
1
- {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AAQA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAgB,MAAM,SAAS,CAAC;AACnF,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;iFAC6E;IAC7E,qBAAqB,IAAI,IAAI,CAAC;IAC9B,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,GAAG,YAAY,CA0HvH"}
1
+ {"version":3,"file":"lens-children.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lens-children.ts"],"names":[],"mappings":"AAQA,OAAO,EAIL,KAAK,UAAU,EAChB,MAAM,kCAAkC,CAAC;AAG1C,OAAO,KAAK,EAAE,cAAc,EAAE,qBAAqB,EAAgB,MAAM,SAAS,CAAC;AAQnF,OAAO,EAA0B,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAM7E,MAAM,WAAW,YAAY;IAC3B,iBAAiB,EAAE,UAAU,CAAC;IAC9B;iFAC6E;IAC7E,qBAAqB,IAAI,IAAI,CAAC;IAC9B,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,EAAE,OAAO,EAAE,qBAAqB,GAAG,YAAY,CAiKvH"}
@@ -11,6 +11,8 @@ const node_child_process_1 = require("node:child_process");
11
11
  const manifest_1 = require("../manifest");
12
12
  const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
13
13
  const scope_1 = require("./scope");
14
+ const lifecycle_1 = require("./lifecycle");
15
+ const types_1 = require("./types");
14
16
  const observability_1 = require("./observability");
15
17
  const HEAL_BASE_MS = 1_500; // first restart delay
16
18
  const HEAL_MAX_MS = 30_000; // backoff cap — a true boot-loop settles here, never hot-loops
@@ -21,6 +23,13 @@ function createLensChildren(rt, obs, options) {
21
23
  // Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
22
24
  // backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
23
25
  const lensHeal = new Map();
26
+ // Zombie recycle: the live `lens:true` app VITE children (label 'vite') + when each spawned, so the lens
27
+ // sweeper can restart one whose fs-watcher missed a post-spawn file add (the reported dashboard zombie).
28
+ // The capture sidecar + the dashboard/MCP singletons are NOT tracked (they don't serve app source).
29
+ const viteChildren = new Map();
30
+ // Per-project last-recycle time — the cooldown survives the kill→auto-heal→respawn (a new child handle).
31
+ const viteRecycleAt = new Map();
32
+ let lensSweeper;
24
33
  // A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
25
34
  // `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
26
35
  // `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
@@ -34,10 +43,15 @@ function createLensChildren(rt, obs, options) {
34
43
  const spawnedAt = Date.now();
35
44
  const child = (0, node_child_process_1.spawn)(bin, args, { cwd: rt.root, env: { ...process.env, ...(env ?? {}) }, stdio: 'inherit' });
36
45
  lensChildren.push(child);
46
+ // Track a `lens:true` app's vite dev server (label 'vite') for the zombie recycle. Its `LENSMCP_PROJECT`
47
+ // (set by lens-children when it spawns the frontend) names the app; a fresh respawn re-adds via this path.
48
+ if (label === 'vite')
49
+ viteChildren.set(child, { project: env?.['LENSMCP_PROJECT'] ?? 'app', spawnedAt });
37
50
  child.on('exit', (code, sig) => {
38
51
  const idx = lensChildren.indexOf(child);
39
52
  if (idx >= 0)
40
53
  lensChildren.splice(idx, 1); // drop the dead handle so stop() won't signal it
54
+ viteChildren.delete(child); // the auto-heal respawn re-registers a fresh handle with a new spawnedAt
41
55
  if (rt.stopped())
42
56
  return; // the gateway is shutting down — do NOT resurrect
43
57
  const heal = lensHeal.get(label) ?? { attempts: 0 };
@@ -53,6 +67,38 @@ function createLensChildren(rt, obs, options) {
53
67
  });
54
68
  return child;
55
69
  };
70
+ // ZOMBIE recycle for lens frontends: restart a `lens:true` app's vite whose fs-watcher went stale — it is
71
+ // ALIVE + reachable yet MISSED files created after it spawned (500 `Failed to resolve import` for a file
72
+ // that exists; the reported 17.7h dashboard pod). The shared `rt.sourceSetChangedAt` (stamped by the
73
+ // lifecycle sweeper's source-set scan) drives it: a vite that spawned before a settled set change is
74
+ // killed → this manager's own `on('exit')` auto-heal respawns a fresh vite that re-scans the FS. Only on a
75
+ // set CHANGE (add/remove), never a content save, and never a blind max-age (no idle signal for a frontend).
76
+ const startLensSweeper = () => {
77
+ if (!types_1.POD_RECYCLE_ENABLED || lensSweeper)
78
+ return;
79
+ const cfg = { graceMs: types_1.POD_STALE_GRACE_MS, settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS };
80
+ lensSweeper = setInterval(() => {
81
+ if (rt.stopped())
82
+ return;
83
+ const changedAt = rt.sourceSetChangedAt ?? 0;
84
+ if (changedAt <= 0 || viteChildren.size === 0)
85
+ return;
86
+ const now = Date.now();
87
+ for (const [child, meta] of viteChildren) {
88
+ const last = viteRecycleAt.get(meta.project) ?? 0;
89
+ if (!(0, lifecycle_1.lensFrontendStale)(meta.spawnedAt, last, changedAt, now, cfg))
90
+ continue;
91
+ viteRecycleAt.set(meta.project, now);
92
+ console.log(`[gateway] zombie recycle: lens app ${meta.project} (stale-source-set) — vite missed a file add; restarting it`);
93
+ emit('warning', `zombie recycle: ${meta.project} (stale-source-set)`, `cluster-lens-app:${meta.project}`, { kind: 'pod-recycle', project: meta.project, reason: 'stale-source-set' });
94
+ try {
95
+ child.kill('SIGTERM');
96
+ }
97
+ catch { /* already gone — the exit auto-heal will respawn */ }
98
+ }
99
+ }, Math.min(types_1.POD_STALE_SCAN_MS, 15_000));
100
+ lensSweeper.unref?.();
101
+ };
56
102
  const bootSingletonsAndApps = () => {
57
103
  const lensApps = [...new Map(rt.routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
58
104
  const wantDashboard = options.dashboard !== false;
@@ -124,8 +170,13 @@ function createLensChildren(rt, obs, options) {
124
170
  console.error(`[gateway] lens app ${app.project}: ${e.message}`);
125
171
  }
126
172
  }
173
+ startLensSweeper(); // watch the just-spawned lens vites for a stale-source-set zombie
127
174
  };
128
175
  const reapAll = () => {
176
+ if (lensSweeper) {
177
+ clearInterval(lensSweeper);
178
+ lensSweeper = undefined;
179
+ }
129
180
  for (const c of lensChildren) {
130
181
  try {
131
182
  c.kill('SIGTERM');
@@ -1,5 +1,36 @@
1
1
  import type { GatewayRuntime, ServiceCtl, SockPool } from './types';
2
2
  import type { Observability } from './observability';
3
+ /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
4
+ * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs, and
5
+ * only counts source-extension files. Order-independent (readdir order irrelevant) and null-safe. */
6
+ export declare function sourceSetSignature(root: string): Promise<string>;
7
+ export interface PodRecycleConfig {
8
+ maxAgeMs: number;
9
+ graceMs: number;
10
+ settleMs: number;
11
+ cooldownMs: number;
12
+ }
13
+ /**
14
+ * PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
15
+ * is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
16
+ * pod is never interrupted) and past its per-service cooldown. Then it recycles if the source file SET
17
+ * changed after it spawned (`stale-source-set`) or as a plain age backstop (`max-age`). Returns `null` when
18
+ * it must be left alone. Kept pure (no `ServiceCtl` mutation, no fs) so the sweeper's logic is unit-tested
19
+ * without spawning a process. `sourceSetChangedAt` is 0 until the gateway first observes a set change.
20
+ */
21
+ export declare function podRecycleReason(svc: Pick<ServiceCtl, 'proc' | 'state' | 'spawnAt' | 'inflight' | 'lastUsed' | 'lastStaleRecycleAt'>, now: number, sourceSetChangedAt: number, cfg: PodRecycleConfig): 'stale-source-set' | 'max-age' | null;
22
+ /**
23
+ * PURE decision for a LENS-FRONTEND vite child (a `lens:true` app's dev server — the reported zombie). It
24
+ * has no in-flight/idle signal like a pod, so it recycles ONLY on a settled source-file-SET change that
25
+ * POST-dates its spawn (the precise "watcher missed a new file" trigger) — never a blind max-age (which
26
+ * could interrupt active work). `killing` it lets the manager's `on('exit')` auto-heal respawn a fresh vite
27
+ * that re-scans the FS. Kept pure + exported so the recycle logic is unit-tested without a process.
28
+ */
29
+ export declare function lensFrontendStale(spawnedAt: number, lastRecycleAt: number, sourceSetChangedAt: number, now: number, cfg: {
30
+ graceMs: number;
31
+ settleMs: number;
32
+ cooldownMs: number;
33
+ }): boolean;
3
34
  export interface ServiceLayer {
4
35
  scanNow(pool: SockPool): number;
5
36
  spawnService(svc: ServiceCtl): void;
@@ -1 +1 @@
1
- {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEpE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AA0BrD,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CA4MvF"}
1
+ {"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/lifecycle.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAapE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AA4CrD;;sGAEsG;AACtG,wBAAsB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAuBtE;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,IAAI,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,GAAG,oBAAoB,CAAC,EACpG,GAAG,EAAE,MAAM,EACX,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,gBAAgB,GACpB,kBAAkB,GAAG,SAAS,GAAG,IAAI,CAQvC;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,aAAa,EAAE,MAAM,EACrB,kBAAkB,EAAE,MAAM,EAC1B,GAAG,EAAE,MAAM,EACX,GAAG,EAAE;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAC7D,OAAO,CAMT;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IAChC,YAAY,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,GAAG,EAAE,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,EAAE,UAAU,GAAG,IAAI,CAAC;IAC/B,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,2EAA2E;IAC3E,YAAY,IAAI,MAAM,CAAC,OAAO,CAAC;CAChC;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,cAAc,EAAE,GAAG,EAAE,aAAa,GAAG,YAAY,CA2PvF"}
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.sourceSetSignature = sourceSetSignature;
4
+ exports.podRecycleReason = podRecycleReason;
5
+ exports.lensFrontendStale = lensFrontendStale;
3
6
  exports.createServiceLayer = createServiceLayer;
4
7
  const tslib_1 = require("tslib");
5
8
  /**
@@ -38,6 +41,103 @@ function eastWestEnv(rt, project) {
38
41
  return env;
39
42
  }
40
43
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ // --- zombie-pod recycle: source-file-SET signature -------------------------------------------------------
45
+ // A pod goes "stale" when the set of source files CHANGES (a file added/removed) after it spawned and its
46
+ // degraded watcher missed it. We detect a set change — NOT a content change — by a cheap order-independent
47
+ // signature over source file PATHS (count + xor of per-path hashes): adding/removing a path flips the
48
+ // signature; editing a file's CONTENTS does not, so ordinary saves never trigger a recycle.
49
+ const SRC_EXCLUDE = new Set(['node_modules', 'dist', 'tmp', 'coverage', 'build', 'out', 'target']);
50
+ const SRC_EXT = new Set([
51
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.sass', '.less',
52
+ '.json', '.html', '.htm', '.vue', '.svelte', '.mdx',
53
+ ]);
54
+ const SCAN_FILE_CAP = 100_000;
55
+ function fnv1a(s) {
56
+ let h = 2166136261;
57
+ for (let i = 0; i < s.length; i += 1) {
58
+ h ^= s.charCodeAt(i);
59
+ h = Math.imul(h, 16777619);
60
+ }
61
+ return h >>> 0;
62
+ }
63
+ /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
64
+ * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs, and
65
+ * only counts source-extension files. Order-independent (readdir order irrelevant) and null-safe. */
66
+ async function sourceSetSignature(root) {
67
+ let count = 0;
68
+ let xor = 0;
69
+ const walk = async (dir) => {
70
+ if (count > SCAN_FILE_CAP)
71
+ return;
72
+ let entries;
73
+ try {
74
+ entries = await fs.promises.readdir(dir, { withFileTypes: true });
75
+ }
76
+ catch {
77
+ return;
78
+ }
79
+ for (const e of entries) {
80
+ if (count > SCAN_FILE_CAP)
81
+ return;
82
+ const name = e.name;
83
+ if (e.isDirectory()) {
84
+ if (name.startsWith('.') || SRC_EXCLUDE.has(name) || name.endsWith('-devserver'))
85
+ continue;
86
+ await walk(path.join(dir, name));
87
+ }
88
+ else if (e.isFile()) {
89
+ const dot = name.lastIndexOf('.');
90
+ if (dot <= 0 || !SRC_EXT.has(name.slice(dot)))
91
+ continue;
92
+ count += 1;
93
+ xor ^= fnv1a(path.join(dir, name));
94
+ }
95
+ }
96
+ };
97
+ await walk(root);
98
+ return `${count}:${(xor >>> 0).toString(36)}`;
99
+ }
100
+ /**
101
+ * PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
102
+ * is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
103
+ * pod is never interrupted) and past its per-service cooldown. Then it recycles if the source file SET
104
+ * changed after it spawned (`stale-source-set`) or as a plain age backstop (`max-age`). Returns `null` when
105
+ * it must be left alone. Kept pure (no `ServiceCtl` mutation, no fs) so the sweeper's logic is unit-tested
106
+ * without spawning a process. `sourceSetChangedAt` is 0 until the gateway first observes a set change.
107
+ */
108
+ function podRecycleReason(svc, now, sourceSetChangedAt, cfg) {
109
+ if (!svc.proc || svc.state !== 'up' || svc.spawnAt == null)
110
+ return null; // only a live, up, spawned pod
111
+ if (svc.inflight > 0)
112
+ return null; // never mid-request
113
+ if (now - svc.lastUsed < cfg.settleMs)
114
+ return null; // wait for an inter-request lull
115
+ if (svc.lastStaleRecycleAt != null && now - svc.lastStaleRecycleAt < cfg.cooldownMs)
116
+ return null; // anti-thrash
117
+ if (sourceSetChangedAt > 0 && sourceSetChangedAt - svc.spawnAt > cfg.graceMs)
118
+ return 'stale-source-set';
119
+ if (cfg.maxAgeMs > 0 && now - svc.spawnAt > cfg.maxAgeMs)
120
+ return 'max-age';
121
+ return null;
122
+ }
123
+ /**
124
+ * PURE decision for a LENS-FRONTEND vite child (a `lens:true` app's dev server — the reported zombie). It
125
+ * has no in-flight/idle signal like a pod, so it recycles ONLY on a settled source-file-SET change that
126
+ * POST-dates its spawn (the precise "watcher missed a new file" trigger) — never a blind max-age (which
127
+ * could interrupt active work). `killing` it lets the manager's `on('exit')` auto-heal respawn a fresh vite
128
+ * that re-scans the FS. Kept pure + exported so the recycle logic is unit-tested without a process.
129
+ */
130
+ function lensFrontendStale(spawnedAt, lastRecycleAt, sourceSetChangedAt, now, cfg) {
131
+ if (now - lastRecycleAt < cfg.cooldownMs)
132
+ return false; // anti-thrash on a source-churn burst
133
+ if (sourceSetChangedAt <= 0)
134
+ return false; // no set change observed yet
135
+ if (sourceSetChangedAt - spawnedAt <= cfg.graceMs)
136
+ return false; // this vite already postdates the change
137
+ if (now - sourceSetChangedAt < cfg.settleMs)
138
+ return false; // let an add/remove burst settle → one recycle
139
+ return true;
140
+ }
41
141
  function createServiceLayer(rt, obs) {
42
142
  const { emit } = obs;
43
143
  const scanNow = (pool) => { pool.scannedAt = 0; return (0, discovery_1.pickSock)(pool, rt.scanTtlMs) ? pool.socks.length : 0; };
@@ -243,8 +343,51 @@ function createServiceLayer(rt, obs) {
243
343
  spawnService(svc);
244
344
  }, 1500).unref?.();
245
345
  };
346
+ // --- zombie-pod recycle: source-set-change tracking + a fresh-pod recycle for a live-but-stale pod ------
347
+ const recycleCfg = {
348
+ maxAgeMs: types_1.POD_MAX_AGE_MS, graceMs: types_1.POD_STALE_GRACE_MS,
349
+ settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS,
350
+ };
351
+ let prevSig;
352
+ let scanInFlight = false;
353
+ let lastScanAt = 0;
354
+ // Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the shared
355
+ // clock so pods that predate it recycle. Non-blocking — the sweeper never awaits it.
356
+ const maybeScanSourceSet = () => {
357
+ if (scanInFlight || Date.now() - lastScanAt < types_1.POD_STALE_SCAN_MS)
358
+ return;
359
+ scanInFlight = true;
360
+ lastScanAt = Date.now();
361
+ void sourceSetSignature(rt.root)
362
+ .then((sig) => {
363
+ if (prevSig !== undefined && sig !== prevSig) {
364
+ rt.sourceSetChangedAt = Date.now();
365
+ emit('info', 'source file set changed — stale pods recycle when idle', 'cluster-gateway', { kind: 'source-set-changed' });
366
+ }
367
+ prevSig = sig;
368
+ })
369
+ .catch(() => undefined)
370
+ .finally(() => { scanInFlight = false; });
371
+ };
372
+ // Recycle a live-but-STALE-VIEW pod (alive + reachable, yet its watcher missed post-spawn file adds).
373
+ // Kill it → scale-from-zero (or eager respawn) → a fresh pod re-scans the FS. NOT counted against the
374
+ // crash-loop cap (staleness ≠ crash); the per-service cooldown ({@link podRecycleReason}) stops thrash.
375
+ const recyclePod = (svc, reason) => {
376
+ svc.lastStaleRecycleAt = Date.now();
377
+ console.log(`[gateway] zombie recycle: ${svc.project} (${reason}) — stale fs view; fresh pod incoming`);
378
+ emit('warning', `zombie recycle: ${svc.project} (${reason})`, `cluster-service:${svc.project}`, { kind: 'pod-recycle', project: svc.project, reason });
379
+ const eager = !!svc.decl.eager;
380
+ killSpawned(svc, `${reason} — recycling stale pod`); // clears proc, state='down', unlinks socks (+1200ms)
381
+ if (eager)
382
+ setTimeout(() => { if (!rt.stopped() && !svc.proc && svc.state === 'down')
383
+ spawnService(svc); }, 1500).unref?.();
384
+ };
246
385
  const startSweeper = () => {
247
386
  const sweeper = setInterval(() => {
387
+ if (rt.stopped())
388
+ return;
389
+ if (types_1.POD_RECYCLE_ENABLED)
390
+ maybeScanSourceSet();
248
391
  for (const svc of rt.services) {
249
392
  if (rt.stopped() || !svc.proc) {
250
393
  svc.wedgedSince = undefined;
@@ -259,8 +402,17 @@ function createServiceLayer(rt, obs) {
259
402
  }
260
403
  // WEDGE detection: a live parent with 0 live pods that is NOT legitimately cold-starting is wedged.
261
404
  const coldStarting = svc.state === 'starting' && svc.spawnAt !== undefined && Date.now() - svc.spawnAt < rt.startupTimeoutMs;
262
- if (scanNow(svc.pool) > 0 || coldStarting) {
405
+ const live = scanNow(svc.pool) > 0;
406
+ if (live || coldStarting) {
263
407
  svc.wedgedSince = undefined;
408
+ // ZOMBIE recycle: a live pod (live parent + live socket, answers requests) whose fs view went
409
+ // stale — neither exit-respawn nor wedge-recycle sees it. Idle-gated in podRecycleReason so a busy
410
+ // pod is never interrupted.
411
+ if (live && !coldStarting && types_1.POD_RECYCLE_ENABLED) {
412
+ const reason = podRecycleReason(svc, Date.now(), rt.sourceSetChangedAt ?? 0, recycleCfg);
413
+ if (reason)
414
+ recyclePod(svc, reason);
415
+ }
264
416
  continue;
265
417
  }
266
418
  svc.wedgedSince ??= Date.now();
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAe,MAAM,SAAS,CAAC;AAEjH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CAkKxB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/server.ts"],"names":[],"mappings":"AAuBA,OAAO,KAAK,EAAE,cAAc,EAAE,aAAa,EAAkB,qBAAqB,EAAsB,MAAM,SAAS,CAAC;AAkDxH,wBAAsB,YAAY,CAChC,OAAO,EAAE,qBAAqB,EAC9B,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,aAAa,CAAC,CA0KxB"}
@@ -24,6 +24,57 @@ const auth_1 = require("./auth");
24
24
  const hooks_1 = require("./hooks");
25
25
  const handler_1 = require("./handler");
26
26
  const upgrade_1 = require("./upgrade");
27
+ const jwks_verify_1 = require("../jwks-verify");
28
+ /**
29
+ * A `fetch`-shaped shim for the JWKS pull that accepts the dev gateway's OWN self-signed cert. The dev
30
+ * IdP's JWKS is served behind the same local HTTPS front door (e.g. `https://auth.tetros.ai.local`), whose
31
+ * cert chains to the local basic-ssl CA — global `fetch` would reject it. Read-only GET of a public,
32
+ * gateway-served JWKS document; the SIGNATURE trust comes from verifying tokens against those keys, not
33
+ * from the transport. Falls back to plain `http` for an http URL. Dev-only (only the dev executor uses it).
34
+ */
35
+ function insecureLocalFetch(url, init) {
36
+ return new Promise((resolve, reject) => {
37
+ const mod = url.startsWith('https:') ? require('node:https') : require('node:http');
38
+ const req = mod.request(url, { method: 'GET', headers: init?.headers, rejectUnauthorized: false }, (res) => {
39
+ const chunks = [];
40
+ res.on('data', (c) => chunks.push(c));
41
+ res.on('end', () => {
42
+ const status = res.statusCode ?? 0;
43
+ const text = Buffer.concat(chunks).toString('utf8');
44
+ resolve({ ok: status >= 200 && status < 300, status, json: async () => JSON.parse(text) });
45
+ });
46
+ });
47
+ req.on('error', reject);
48
+ req.end();
49
+ });
50
+ }
51
+ /**
52
+ * The dev edge's JWT verifier — matches the prod gateway's RS256/JWKS posture so the LOCAL edge rejects
53
+ * forged tokens instead of decode-accepting them. The JWKS URL is `LENSMCP_GW_JWKS_URL` (explicit, same
54
+ * env as prod) or, absent that, is AUTO-DERIVED from the trusted `auth.*` host in the gateway's own route
55
+ * table (`https://<auth-host>/jwks.json`) — so a standard `auth.<apex>` IdP needs zero config. The host is
56
+ * taken from project.json (trusted), never from a token, so there's no SSRF/attacker-chosen JWKS. Returns
57
+ * undefined when no source is found (→ non-HS256 tokens fail closed unless the insecure opt-in is set).
58
+ */
59
+ function buildEdgeJwtVerifier(routes) {
60
+ const explicit = process.env['LENSMCP_GW_JWKS_URL'];
61
+ let jwksUrl = explicit;
62
+ let derived = false;
63
+ if (!jwksUrl) {
64
+ const authHost = routes
65
+ .map((r) => r.host)
66
+ .find((h) => typeof h === 'string' && /^auth\./.test(h) && !h.startsWith('internal.'));
67
+ if (authHost) {
68
+ jwksUrl = `https://${authHost}/jwks.json`;
69
+ derived = true;
70
+ }
71
+ }
72
+ if (!jwksUrl)
73
+ return undefined;
74
+ const issuer = process.env['LENSMCP_GW_JWT_ISS'];
75
+ console.log(`[gateway] edge JWT: verifying via JWKS ${jwksUrl}${derived ? ' (auto-derived from the auth route)' : ''}`);
76
+ return (0, jwks_verify_1.createJwksVerifier)({ jwksUrl, ...(issuer ? { issuer } : {}), fetchImpl: insecureLocalFetch });
77
+ }
27
78
  async function startGateway(options, context) {
28
79
  // NB: this local `https` (TLS-on flag) deliberately SHADOWS the `node:https`
29
80
  // module — which is why TLS/agents are reached via `require('node:https')`.
@@ -65,7 +116,15 @@ async function startGateway(options, context) {
65
116
  const svcLayer = (0, lifecycle_1.createServiceLayer)(rt, obs);
66
117
  const lens = (0, lens_children_1.createLensChildren)(rt, obs, options);
67
118
  const proxyLayer = (0, proxy_1.createProxy)(rt, obs, svcLayer);
68
- const edgeAuth = (0, auth_1.createEdgeAuth)(rt, obs);
119
+ // Edge JWT verification (mirrors the prod gateway): when a JWKS source is configured/derivable, the dev
120
+ // edge verifies RS256 tokens by `kid` against the IdP's published keys and REJECTS anything it can't
121
+ // verify (rogue kid / bad sig / alg:none / HS256-confusion / expired) — closing the dev decode-only bypass.
122
+ const jwtVerifier = buildEdgeJwtVerifier(routes);
123
+ const edgeAuth = (0, auth_1.createEdgeAuth)(rt, obs, jwtVerifier);
124
+ // Warm the key cache (non-fatal, non-blocking): the IdP pod may still be booting, so a failure here just
125
+ // means the first tokens fail closed until the on-miss/periodic refresh lands the keys (matches prod).
126
+ if (jwtVerifier)
127
+ void jwtVerifier.refresh().catch((e) => console.warn('[gateway] edge JWKS warm failed (will retry):', e.message));
69
128
  // Custom user middleware (the JWT seam; internal.* routes skip it) is wired as
70
129
  // the BUILT-IN FIRST `afterAuth` hook — so "custom middleware runs AFTER core
71
130
  // auth" and the `{ mode:'middleware' }` trace step are preserved.
@@ -167,6 +226,7 @@ async function startGateway(options, context) {
167
226
  clearInterval(edgeFlusher);
168
227
  clearInterval(sweeper);
169
228
  obs.emit('info', 'gateway down', 'cluster-gateway', { kind: 'gateway-down' });
229
+ jwtVerifier?.stop(); // clear the JWKS periodic-refresh timer
170
230
  for (const svc of services)
171
231
  svcLayer.killSpawned(svc, 'gateway shutdown');
172
232
  // Reap the lens-mode managed children (dashboard + MCP singletons + each
@@ -131,6 +131,11 @@ export interface ServiceCtl {
131
131
  * cold-starting). Cleared once a pod appears; once it exceeds {@link WEDGE_RECYCLE_GRACE_MS} the sweeper
132
132
  * recycles the wedged tree. */
133
133
  wedgedSince?: number;
134
+ /** Zombie recycle: when this pod was last recycled for STALENESS/age (a live-but-stale-view pod, distinct
135
+ * from a crash or a wedge). A per-service cooldown ({@link POD_STALE_RECYCLE_COOLDOWN_MS}) keyed off this
136
+ * stops a rapid source-churn (e.g. a codegen run) from thrash-recycling the same pod. NOT counted against
137
+ * the crash-loop cap ({@link restarts}) — staleness is not a crash. */
138
+ lastStaleRecycleAt?: number;
134
139
  }
135
140
  /** Min interval (ms) between repeat 'service error' emits for the SAME service + same error line. */
136
141
  export declare const SERVICE_ERROR_THROTTLE_MS = 5000;
@@ -162,6 +167,36 @@ export declare const POD_RESPONSE_TIMEOUT_MS: number;
162
167
  * legit in-process hot-swap (the socket is briefly gone, then re-bound). A code (build) error is left to
163
168
  * the user's fix but still recycled under the crash-loop cap. Override with `LENSMCP_WEDGE_RECYCLE_GRACE_MS`. */
164
169
  export declare const WEDGE_RECYCLE_GRACE_MS: number;
170
+ /**
171
+ * ZOMBIE-POD RECYCLE (dev only). The exit-respawn (crashed pod) and wedge-recycle (live parent, 0 live
172
+ * pods) self-heals both assume a DEAD or SOCKET-LESS pod. This one catches the third kind: a pod that is
173
+ * ALIVE and reachable (a live socket, answers requests) yet serves a STALE FILESYSTEM VIEW — its dev-server
174
+ * file-watcher degraded (macOS fsevents after long uptime / a sleep-wake) and MISSED files created AFTER it
175
+ * spawned, so it 500s `Failed to resolve import "./x"` for a file that EXISTS on disk (the reported
176
+ * incident: a 17.7h-old vite pod that predated new files by ~6h). The sweeper detects it two ways and
177
+ * recycles the pod (kill → scale-from-zero → a fresh pod re-scans the FS) — but ONLY when the pod is idle
178
+ * (no in-flight request + a brief lull), so an active pod is never interrupted.
179
+ */
180
+ /** Master switch — set `LENSMCP_POD_RECYCLE=0` to disable zombie-pod recycling entirely. Default ON (dev). */
181
+ export declare const POD_RECYCLE_ENABLED: boolean;
182
+ /** Backstop: recycle a gateway-spawned pod once it is older than this, even with no detected source change
183
+ * (a long-lived pod is the one whose watcher degrades). `0` disables the age backstop. Override
184
+ * `LENSMCP_POD_MAX_AGE_MS`. Default 4h. */
185
+ export declare const POD_MAX_AGE_MS: number;
186
+ /** A pod is "stale" when the source file SET changed (a file added/removed — NOT a content change, so
187
+ * ordinary saves never recycle) more than this AFTER the pod spawned. The grace absorbs clock skew + the
188
+ * pod's own first-compile writes. Override `LENSMCP_POD_STALE_GRACE_MS`. Default 10s. */
189
+ export declare const POD_STALE_GRACE_MS: number;
190
+ /** Recycle only after the pod has been quiet this long (0 in-flight AND lastUsed older than this) — a busy
191
+ * pod is never interrupted; the recycle lands in an inter-request lull. Override
192
+ * `LENSMCP_POD_RECYCLE_SETTLE_MS`. Default 8s. */
193
+ export declare const POD_RECYCLE_SETTLE_MS: number;
194
+ /** How often the sweeper rescans the workspace source file SET signature (a bounded, async, non-blocking
195
+ * walk). Override `LENSMCP_POD_STALE_SCAN_MS`. Default 30s. */
196
+ export declare const POD_STALE_SCAN_MS: number;
197
+ /** Per-service cooldown between staleness recycles — stops a source-churn burst (codegen) thrash-recycling
198
+ * the same pod. Override `LENSMCP_POD_STALE_RECYCLE_COOLDOWN_MS`. Default 60s. */
199
+ export declare const POD_STALE_RECYCLE_COOLDOWN_MS: number;
165
200
  /** Test-tunable knobs on top of the user-facing schema. */
166
201
  export interface GatewayRuntimeOptions extends GatewayExecutorSchema {
167
202
  /** Traffic-edge flush period (ms). Default 5000. */
@@ -212,6 +247,11 @@ export interface GatewayRuntime {
212
247
  readonly sweepMs: number;
213
248
  readonly trafficFlushMs: number;
214
249
  readonly https: boolean;
250
+ /** Zombie-pod recycle: the last time the workspace source FILE SET changed (a file added/removed), stamped
251
+ * by the lifecycle sweeper's periodic scan. SHARED so both managers — the pod sweeper (lifecycle) and the
252
+ * lens-frontend sweeper (lens-children) — recycle any child that spawned before it. `0`/undefined until
253
+ * the first change is observed. Mutable (the one non-readonly runtime field, written only by the scanner). */
254
+ sourceSetChangedAt?: number;
215
255
  /** Teardown flag — a GETTER, never a captured boolean, so every layer reads
216
256
  * the LIVE value (an in-flight cold start / auto-heal must not act after stop). */
217
257
  stopped(): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../../../libs/cluster/src/executors/gateway/runtime/types.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,16 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.WEDGE_RECYCLE_GRACE_MS = exports.POD_RESPONSE_TIMEOUT_MS = exports.UPSTREAM_HEAL_STEP_MS = exports.UPSTREAM_HEAL_WINDOW_MS = exports.SERVICE_RESTART_WINDOW_MS = exports.SERVICE_MAX_RESTARTS = exports.SERVICE_ERROR_THROTTLE_MS = void 0;
3
+ exports.POD_STALE_RECYCLE_COOLDOWN_MS = exports.POD_STALE_SCAN_MS = exports.POD_RECYCLE_SETTLE_MS = exports.POD_STALE_GRACE_MS = exports.POD_MAX_AGE_MS = exports.POD_RECYCLE_ENABLED = exports.WEDGE_RECYCLE_GRACE_MS = exports.POD_RESPONSE_TIMEOUT_MS = exports.UPSTREAM_HEAL_STEP_MS = exports.UPSTREAM_HEAL_WINDOW_MS = exports.SERVICE_RESTART_WINDOW_MS = exports.SERVICE_MAX_RESTARTS = exports.SERVICE_ERROR_THROTTLE_MS = void 0;
4
+ /** Parse a positive-integer env override; fall back to `def` on missing/NaN/negative. `allowZero` lets a
5
+ * `0` through (used as a "disable" sentinel, e.g. {@link POD_MAX_AGE_MS}); otherwise `0` also falls back. */
6
+ function envInt(name, def, allowZero = false) {
7
+ const n = Number(process.env[name]);
8
+ if (!Number.isFinite(n) || n < 0)
9
+ return def;
10
+ if (n === 0)
11
+ return allowZero ? 0 : def;
12
+ return n;
13
+ }
4
14
  /** Min interval (ms) between repeat 'service error' emits for the SAME service + same error line. */
5
15
  exports.SERVICE_ERROR_THROTTLE_MS = 5000;
6
16
  /** Self-heal: an unexpectedly-exited pod (crash, not a clean exit or a build error) is auto-respawned
@@ -37,3 +47,33 @@ exports.WEDGE_RECYCLE_GRACE_MS = (() => {
37
47
  const n = Number(process.env['LENSMCP_WEDGE_RECYCLE_GRACE_MS']);
38
48
  return Number.isFinite(n) && n > 0 ? n : 15_000;
39
49
  })();
50
+ /**
51
+ * ZOMBIE-POD RECYCLE (dev only). The exit-respawn (crashed pod) and wedge-recycle (live parent, 0 live
52
+ * pods) self-heals both assume a DEAD or SOCKET-LESS pod. This one catches the third kind: a pod that is
53
+ * ALIVE and reachable (a live socket, answers requests) yet serves a STALE FILESYSTEM VIEW — its dev-server
54
+ * file-watcher degraded (macOS fsevents after long uptime / a sleep-wake) and MISSED files created AFTER it
55
+ * spawned, so it 500s `Failed to resolve import "./x"` for a file that EXISTS on disk (the reported
56
+ * incident: a 17.7h-old vite pod that predated new files by ~6h). The sweeper detects it two ways and
57
+ * recycles the pod (kill → scale-from-zero → a fresh pod re-scans the FS) — but ONLY when the pod is idle
58
+ * (no in-flight request + a brief lull), so an active pod is never interrupted.
59
+ */
60
+ /** Master switch — set `LENSMCP_POD_RECYCLE=0` to disable zombie-pod recycling entirely. Default ON (dev). */
61
+ exports.POD_RECYCLE_ENABLED = process.env['LENSMCP_POD_RECYCLE'] !== '0';
62
+ /** Backstop: recycle a gateway-spawned pod once it is older than this, even with no detected source change
63
+ * (a long-lived pod is the one whose watcher degrades). `0` disables the age backstop. Override
64
+ * `LENSMCP_POD_MAX_AGE_MS`. Default 4h. */
65
+ exports.POD_MAX_AGE_MS = envInt('LENSMCP_POD_MAX_AGE_MS', 4 * 60 * 60_000, true);
66
+ /** A pod is "stale" when the source file SET changed (a file added/removed — NOT a content change, so
67
+ * ordinary saves never recycle) more than this AFTER the pod spawned. The grace absorbs clock skew + the
68
+ * pod's own first-compile writes. Override `LENSMCP_POD_STALE_GRACE_MS`. Default 10s. */
69
+ exports.POD_STALE_GRACE_MS = envInt('LENSMCP_POD_STALE_GRACE_MS', 10_000);
70
+ /** Recycle only after the pod has been quiet this long (0 in-flight AND lastUsed older than this) — a busy
71
+ * pod is never interrupted; the recycle lands in an inter-request lull. Override
72
+ * `LENSMCP_POD_RECYCLE_SETTLE_MS`. Default 8s. */
73
+ exports.POD_RECYCLE_SETTLE_MS = envInt('LENSMCP_POD_RECYCLE_SETTLE_MS', 8_000);
74
+ /** How often the sweeper rescans the workspace source file SET signature (a bounded, async, non-blocking
75
+ * walk). Override `LENSMCP_POD_STALE_SCAN_MS`. Default 30s. */
76
+ exports.POD_STALE_SCAN_MS = envInt('LENSMCP_POD_STALE_SCAN_MS', 30_000);
77
+ /** Per-service cooldown between staleness recycles — stops a source-churn burst (codegen) thrash-recycling
78
+ * the same pod. Override `LENSMCP_POD_STALE_RECYCLE_COOLDOWN_MS`. Default 60s. */
79
+ exports.POD_STALE_RECYCLE_COOLDOWN_MS = envInt('LENSMCP_POD_STALE_RECYCLE_COOLDOWN_MS', 60_000);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lensmcp/cluster",
3
- "version": "1.16.5",
3
+ "version": "1.16.8",
4
4
  "description": "Run your Nx workspace as a local production cluster: pods on unix sockets with true HMR, one https gateway with per-project domains, scale-from-zero, autoscale, idle-kill, local-CA TLS — observed by the LensMCP lens.",
5
5
  "main": "./index.js",
6
6
  "types": "./index.d.ts",
@@ -65,8 +65,8 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@lensmcp/node-instrumentation": "1.16.5",
69
- "@lensmcp/nx-plugin": "1.16.5",
68
+ "@lensmcp/node-instrumentation": "1.16.8",
69
+ "@lensmcp/nx-plugin": "1.16.8",
70
70
  "fork-ts-checker-webpack-plugin": "^9.0.0",
71
71
  "glob": "^11.0.0",
72
72
  "http-proxy": "^1.18.0",