@lensmcp/cluster 1.18.4 → 1.18.7

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 (64) hide show
  1. package/basic-ssl.js +1 -241
  2. package/build-scope-patterns.js +1 -40
  3. package/create-webpack-dev.js +1 -186
  4. package/create-webpack-prod.js +1 -169
  5. package/executors/build/build.impl.js +1 -98
  6. package/executors/gateway/gateway-errors.js +1 -43
  7. package/executors/gateway/gateway.impl.js +1 -53
  8. package/executors/gateway/gateway.lib.js +1 -29
  9. package/executors/gateway/health-check.js +1 -66
  10. package/executors/gateway/jwks-verify.js +1 -121
  11. package/executors/gateway/main.prod-gateway.js +2 -573
  12. package/executors/gateway/main.rollout.js +11 -117
  13. package/executors/gateway/manifest.js +1 -374
  14. package/executors/gateway/metrics.js +1 -56
  15. package/executors/gateway/otel-tracing.js +1 -74
  16. package/executors/gateway/prod-gateway.lib.js +1 -22
  17. package/executors/gateway/prod-runtime/access-log.js +1 -24
  18. package/executors/gateway/prod-runtime/app.js +1 -123
  19. package/executors/gateway/prod-runtime/auth.js +1 -51
  20. package/executors/gateway/prod-runtime/cors.js +1 -40
  21. package/executors/gateway/prod-runtime/edge.js +1 -65
  22. package/executors/gateway/prod-runtime/handler.js +1 -226
  23. package/executors/gateway/prod-runtime/hooks.js +1 -42
  24. package/executors/gateway/prod-runtime/observability.js +1 -125
  25. package/executors/gateway/prod-runtime/rollout.js +1 -103
  26. package/executors/gateway/prod-runtime/routing.js +1 -40
  27. package/executors/gateway/prod-runtime/server.js +1 -79
  28. package/executors/gateway/prod-runtime/trust.js +1 -32
  29. package/executors/gateway/prod-runtime/types.js +1 -2
  30. package/executors/gateway/prod-runtime/upgrade.js +4 -116
  31. package/executors/gateway/prod-runtime/upstream.js +1 -21
  32. package/executors/gateway/providers-prod.js +1 -232
  33. package/executors/gateway/rate-limit.js +2 -75
  34. package/executors/gateway/registry-source.js +1 -131
  35. package/executors/gateway/rollout-ops.js +2 -167
  36. package/executors/gateway/runtime/auth.js +1 -64
  37. package/executors/gateway/runtime/chooser.js +12 -45
  38. package/executors/gateway/runtime/control.js +1 -128
  39. package/executors/gateway/runtime/dev-auth.js +1 -108
  40. package/executors/gateway/runtime/discovery.js +1 -123
  41. package/executors/gateway/runtime/edge.js +1 -47
  42. package/executors/gateway/runtime/handler.js +1 -183
  43. package/executors/gateway/runtime/hooks.js +1 -55
  44. package/executors/gateway/runtime/lens-children.js +1 -651
  45. package/executors/gateway/runtime/lifecycle.js +3 -842
  46. package/executors/gateway/runtime/observability.js +2 -148
  47. package/executors/gateway/runtime/pod-env.js +2 -89
  48. package/executors/gateway/runtime/proxy.js +1 -457
  49. package/executors/gateway/runtime/route-registry.js +1 -72
  50. package/executors/gateway/runtime/scope.js +1 -117
  51. package/executors/gateway/runtime/server.js +3 -487
  52. package/executors/gateway/runtime/service-keys.js +1 -49
  53. package/executors/gateway/runtime/types.js +1 -151
  54. package/executors/gateway/runtime/upgrade.js +1 -71
  55. package/executors/gateway/runtime/workspace-registry.js +1 -99
  56. package/executors/gateway/ssrf-guard.js +1 -190
  57. package/executors/serve/serve.impl.js +1 -280
  58. package/executors/trust/trust.impl.js +4 -162
  59. package/gateway.js +1 -35
  60. package/index.js +1 -16
  61. package/main.devserver.js +10 -1117
  62. package/package.json +4 -3
  63. package/tsgo-check-plugin.js +4 -364
  64. package/typecheck-bus.js +4 -256
@@ -1,651 +1 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.probePortFree = probePortFree;
4
- exports.probePortListening = probePortListening;
5
- exports.waitForPortFree = waitForPortFree;
6
- exports.viteArgPort = viteArgPort;
7
- exports.createLensChildren = createLensChildren;
8
- const tslib_1 = require("tslib");
9
- /**
10
- * Lens-mode managed children: the dashboard + MCP SINGLETONS (cluster-wide,
11
- * owned by the gateway), and each `lens:true` app's Vite + browser capture.
12
- * EVERY managed child AUTO-HEALS — restarted with exponential backoff when it
13
- * exits while the gateway is up — and a dead child NEVER kills the cluster.
14
- */
15
- const net = tslib_1.__importStar(require("node:net"));
16
- const path = tslib_1.__importStar(require("node:path"));
17
- const node_child_process_1 = require("node:child_process");
18
- const lens_frontend_1 = require("@lensmcp/nx-plugin/lens-frontend");
19
- const pod_env_1 = require("./pod-env");
20
- const scope_1 = require("./scope");
21
- const workspace_registry_1 = require("./workspace-registry");
22
- const lifecycle_1 = require("./lifecycle");
23
- const types_1 = require("./types");
24
- const observability_1 = require("./observability");
25
- const HEAL_BASE_MS = 1_500; // first restart delay
26
- const HEAL_MAX_MS = 30_000; // backoff cap — a true boot-loop settles here, never hot-loops
27
- const HEAL_HEALTHY_MS = 20_000; // ran at least this long ⇒ a FRESH crash ⇒ reset the backoff budget
28
- /** True when `port` can actually be BOUND on this machine right now. The in-process `used*Ports` sets only
29
- * know what THIS daemon claimed — an ORPHANED child of a CRASHED daemon (or any foreign process) still
30
- * holds its port at the OS level. Routing to such a port serves the WRONG workspace's content: the child
31
- * we spawn crash-loops on EADDRINUSE while the route proxies into the stale squatter (the "tetros
32
- * dashboard redirects to foodguard" bug). */
33
- /** One bind attempt. `in-use` only for the errnos that actually mean "someone has it"; any other failure
34
- * means that ADDRESS FAMILY is unavailable on this machine (no IPv6, for instance), which is not evidence
35
- * either way and must never be reported as "taken" — that would make `claimFreePort` step up forever. */
36
- function tryBind(port, host) {
37
- return new Promise((resolve) => {
38
- const probe = net.createServer();
39
- probe.unref();
40
- probe.once('error', (e) => {
41
- const code = e.code ?? '';
42
- resolve(code === 'EADDRINUSE' || code === 'EACCES' ? 'in-use' : 'unavailable');
43
- });
44
- probe.listen(host === undefined ? { port } : { port, host }, () => probe.close(() => resolve('free')));
45
- });
46
- }
47
- async function probePortFree(port) {
48
- // The WILDCARD bind alone is NOT enough, and that hole defeated the very purpose of this probe. MEASURED:
49
- // with a holder on `127.0.0.1:P`, `listen({ port: P })` (the wildcard) reports **FREE** — a wildcard bind
50
- // does not collide with a specific-address bind on macOS/BSD. Our children bind the NAME `localhost`
51
- // (vite `--port N --strictPort`, the dashboard bundles), i.e. a LOOPBACK address — exactly the case the
52
- // wildcard probe cannot see. So an orphaned vite holding `127.0.0.1:4200` looked free, `claimFreePort`
53
- // handed 4200 straight back out, and the fresh child died on `listen EADDRINUSE` — the self-inflicted
54
- // crash this probe exists to prevent (and what makes `waitForPortFree` meaningful rather than a no-op).
55
- // Free means bindable on the wildcard AND on both loopback addresses; unavailable families are skipped.
56
- for (const host of [undefined, '127.0.0.1', '::1']) {
57
- if ((await tryBind(port, host)) === 'in-use')
58
- return false;
59
- }
60
- return true;
61
- }
62
- /** A single-address TCP connect: does `host:port` ACCEPT right now? Connect-and-destroy, never an HTTP
63
- * request — a WEDGED vite may accept then hang forever, so a `GET` would time out even where the listener
64
- * is fine (and would pollute the dev server's log). Every timer/socket is unref'd: a probe must never hold
65
- * the gateway process open. */
66
- function probeConnect(host, port, timeoutMs) {
67
- return new Promise((resolve) => {
68
- let done = false;
69
- const sock = net.createConnection({ host, port });
70
- sock.unref();
71
- const timer = setTimeout(() => finish(false), timeoutMs);
72
- timer.unref?.();
73
- function finish(ok) {
74
- if (done)
75
- return;
76
- done = true;
77
- clearTimeout(timer);
78
- sock.destroy();
79
- resolve(ok);
80
- }
81
- sock.once('connect', () => finish(true));
82
- sock.once('error', () => finish(false));
83
- });
84
- }
85
- /** True when something is LISTENING on `port` on loopback — the liveness signal the exit handler cannot
86
- * give us ({@link LENS_PROBE_ENABLED}: a vite alive with zero LISTEN sockets).
87
- *
88
- * BOTH loopback families are tried before reporting a failure. Vite binds the NAME `localhost`, which can
89
- * resolve to `::1` on a v6-preferring host — probing only `127.0.0.1` there would report every healthy dev
90
- * server as wedged and SIGKILL the whole cluster in a loop. The v6 attempt only runs on the v4 failure
91
- * path, so the healthy case stays one connect. */
92
- async function probePortListening(port, timeoutMs = 1_000) {
93
- if (await probeConnect('127.0.0.1', port, timeoutMs))
94
- return true;
95
- return probeConnect('::1', port, timeoutMs);
96
- }
97
- /** Unref'd sleep — a heal/backoff wait must never keep the gateway alive past `stop()`. */
98
- const sleepUnref = (ms) => new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
99
- /**
100
- * Poll until `port` is actually BINDABLE, up to `timeoutMs`. Resolves `true` when it frees, `false` on
101
- * timeout (the caller then spawns anyway and lets the normal heal handle the fallout).
102
- *
103
- * WHY a respawn must await this: a dying vite can hold its listener for seconds (open HMR websockets keep
104
- * the server closing), so the fixed 1.5s heal delay raced it and the fresh child died on
105
- * `listen EADDRINUSE` — a self-inflicted crash that burned a heal attempt and lengthened the outage
106
- * (see {@link PORT_FREE_WAIT_MS}). Probes at least ONCE even with `timeoutMs: 0`, so the common already-free
107
- * case costs a single probe and no delay.
108
- */
109
- async function waitForPortFree(port, timeoutMs, deps = {}) {
110
- const isFree = deps.isPortFree ?? probePortFree;
111
- const intervalMs = Math.max(1, deps.intervalMs ?? types_1.PORT_FREE_POLL_MS);
112
- const deadline = Date.now() + Math.max(0, timeoutMs);
113
- for (;;) {
114
- if (await isFree(port))
115
- return true;
116
- const left = deadline - Date.now();
117
- if (left <= 0)
118
- return false;
119
- await sleepUnref(Math.min(intervalMs, left));
120
- }
121
- }
122
- /** The `--port <n>` / `--port=<n>` a vite child was launched with. `spawnLensFrontend` always PINS the port
123
- * for a gateway-routed app (`--port <n> --strictPort`), so argv is the authoritative record of which port
124
- * this child owns — the port the liveness probe checks and the port its respawn must wait to be free.
125
- * `undefined` for an unpinned child (nothing to probe, nothing to wait for). */
126
- function viteArgPort(args) {
127
- const valid = (n) => (Number.isInteger(n) && n > 0 && n < 65_536 ? n : undefined);
128
- for (let i = 0; i < args.length; i += 1) {
129
- const a = args[i];
130
- if (a === '--port')
131
- return valid(Number(args[i + 1]));
132
- if (a.startsWith('--port='))
133
- return valid(Number(a.slice('--port='.length)));
134
- }
135
- return undefined;
136
- }
137
- function createLensChildren(rt, obs, options,
138
- // Injectable OS-level port probe (unit tests stub it; real ports are machine-dependent).
139
- deps) {
140
- const { emit } = obs;
141
- const isPortFree = deps?.isPortFree ?? probePortFree;
142
- const lensChildren = [];
143
- // Auto-heal bookkeeping per child label: consecutive restart attempts (drives the exponential
144
- // backoff) so a boot-loop settles instead of hammering, reset once a child has run healthily.
145
- // `portConflicts` counts CONSECUTIVE lost bind races (EADDRINUSE) — forgiven rather than charged to
146
- // `attempts`, but capped so a permanently-squatted port still falls back to backoff.
147
- const lensHeal = new Map();
148
- const viteChildren = new Map();
149
- // Per-project last-recycle time — the cooldown survives the kill→auto-heal→respawn (a new child handle).
150
- const viteRecycleAt = new Map();
151
- let lensSweeper;
152
- // Per-workspace dashboard hosting (P3d): the current child by label, labels we've intentionally removed
153
- // (so auto-heal does NOT respawn them on the reap kill), and the dashboard ports already in use (so two
154
- // workspaces' dashboards get distinct ports).
155
- const labeledChildren = new Map();
156
- const removedLabels = new Set();
157
- const usedDashboardPorts = new Set();
158
- // Lens-app vite ports already claimed (the daemon's own apps + every registered workspace's), so a
159
- // registered workspace whose app declares a port already in use gets offset (vite's --strictPort would
160
- // otherwise exit-loop). Keyed by the actual bound port.
161
- const usedAppPorts = new Set();
162
- // The FIRST port at/above `desired` that is neither claimed in-process nor held at the OS level (an
163
- // orphaned child of a crashed daemon squats its old port — see probePortFree). Claims it in `claimed`.
164
- const claimFreePort = async (desired, claimed) => {
165
- let port = desired;
166
- while (claimed.has(port) || !(await isPortFree(port)))
167
- port += 1;
168
- claimed.add(port);
169
- if (port !== desired) {
170
- console.warn(`[gateway] port ${desired} is unavailable (another process holds it — an orphan from a crashed gateway?) — using ${port} instead.`);
171
- }
172
- return port;
173
- };
174
- // A `SpawnChild` over the gateway's managed-children array — the SAME shape `agent-dev` passes to
175
- // `spawnLensFrontend`. EVERY gateway-managed child AUTO-HEALS: the dashboard + MCP singletons AND each
176
- // `lens:true` vite app are restarted when they exit while the gateway is up, with exponential backoff.
177
- // So a transient crash (a dev-server wedge, an OOM) recovers on its own — the user never hand-restarts —
178
- // and a child that crashes-on-boot backs off to HEAL_MAX_MS rather than spinning. A child that ran
179
- // healthily (≥ HEAL_HEALTHY_MS) gets a fresh budget, so one that dies after an hour comes back fast. A
180
- // dead child still NEVER kills the cluster — it simply respawns in the background.
181
- const spawnManagedChild = (label, bin, args, env, optional = false, respawn,
182
- // Per-child working dir. The daemon's OWN children run in `rt.root`; a REGISTERED workspace's vite must
183
- // run in ITS root (so vite loads the right .env / resolves its own context). Threaded through auto-heal.
184
- cwd) => {
185
- void optional;
186
- void respawn; // legacy knobs — auto-heal is now universal (signature kept for compat)
187
- const spawnedAt = Date.now();
188
- // LENSMCP_PARENT_PID: our own bundles (dashboard/MCP) run a ppid watchdog against it and SELF-EXIT when
189
- // this gateway dies ungracefully (crash/SIGKILL — reapAll never ran), so they don't orphan and squat
190
- // their ports for the next gateway to route into.
191
- const child = (0, node_child_process_1.spawn)(bin, args, {
192
- cwd: cwd ?? rt.root,
193
- // FOREIGN-POD ENV SCRUB (pod-env.ts): a registered workspace's vite/child runs in ITS root and must
194
- // not inherit the daemon host's dotenv-loaded env (vite, like nx, keeps an already-set var over the
195
- // workspace's own .env) — the caller's explicit `env` overlay still applies on top.
196
- env: { ...(0, pod_env_1.podBaseEnv)(process.env, rt.root, cwd ?? rt.root), ...(env ?? {}), LENSMCP_PARENT_PID: String(process.pid) },
197
- stdio: 'inherit',
198
- });
199
- lensChildren.push(child);
200
- labeledChildren.set(label, child); // the CURRENT handle for this label (a per-workspace dashboard reap targets it)
201
- // Track a `lens:true` app's vite dev server (label 'vite', or `<ws>:<proj>:vite` for a registered
202
- // workspace) for the zombie recycle + the liveness probe. Its `LENSMCP_PROJECT` names the app and its
203
- // argv pins the port; a fresh respawn re-adds here with a clean probe run.
204
- const isVite = label === 'vite' || label.endsWith(':vite');
205
- // The port THIS child owns (pinned `--port <n> --strictPort`): what the liveness probe checks and what
206
- // the respawn must wait to be free. `undefined` for the dashboard/MCP singletons + an unpinned vite.
207
- const ownPort = isVite ? viteArgPort(args) : undefined;
208
- if (isVite) {
209
- // `spawnLensFrontend` always stamps the OWNING workspace's bus into the child env, so this is
210
- // the authoritative "whose app is this" — a registered workspace's lifecycle events go to its
211
- // own `.lensmcp/events.jsonl`, exactly as the service layer's `emitSvc` routes pod events.
212
- const childBus = typeof env?.['LENSMCP_EVENT_FILE'] === 'string' ? env['LENSMCP_EVENT_FILE'] : undefined;
213
- viteChildren.set(child, {
214
- project: env?.['LENSMCP_PROJECT'] ?? 'app',
215
- spawnedAt,
216
- port: ownPort,
217
- probeFails: 0,
218
- listening: false,
219
- ...(childBus && childBus !== rt.eventFile ? { bus: childBus } : {}),
220
- });
221
- }
222
- child.on('exit', (code, sig) => {
223
- const idx = lensChildren.indexOf(child);
224
- if (idx >= 0)
225
- lensChildren.splice(idx, 1); // drop the dead handle so stop() won't signal it
226
- viteChildren.delete(child); // the auto-heal respawn re-registers a fresh handle with a new spawnedAt
227
- if (rt.stopped() || removedLabels.has(label))
228
- return; // shutting down, or this workspace unregistered — do NOT resurrect
229
- const ranMs = Date.now() - spawnedAt;
230
- const heal = lensHeal.get(label) ?? { attempts: 0, portConflicts: 0 };
231
- // The heal is ASYNC from here: classifying a bind race and waiting for the port both need a probe.
232
- // Every wait is unref'd and every resume re-checks stopped()/removedLabels, so shutdown still wins.
233
- void (async () => {
234
- // BIND RACE vs CRASH. A fast non-zero exit while ANOTHER process still holds our pinned port is
235
- // vite's `--strictPort` losing an EADDRINUSE race with the child it replaced — an environment
236
- // condition, not a code fault. Charging it to `attempts` is what pushed the observed heal to
237
- // "attempt 2" and doubled the outage; forgive it (bounded) and wait the port out instead.
238
- const portHeld = ownPort !== undefined && !(await isPortFree(ownPort));
239
- const raceLost = (0, lifecycle_1.isPortConflictExit)({ code, signal: sig, ranMs, portHeld }, { fastFailMs: types_1.CHILD_START_FAIL_MS });
240
- if (raceLost && heal.portConflicts < types_1.MAX_PORT_CONFLICT_RETRIES) {
241
- heal.portConflicts += 1; // `attempts` UNTOUCHED — the crash-loop budget is for real crashes
242
- lensHeal.set(label, heal);
243
- console.warn(`[gateway] lens child ${label} lost the :${ownPort} bind race (EADDRINUSE, code=${code}); waiting for the port, then retrying (port-retry ${heal.portConflicts}/${types_1.MAX_PORT_CONFLICT_RETRIES}; crash budget untouched).`);
244
- }
245
- else {
246
- heal.portConflicts = 0; // a real exit ends the bind-race run
247
- if (ranMs >= HEAL_HEALTHY_MS)
248
- heal.attempts = 0; // it was healthy → fresh budget
249
- const delay = Math.min(HEAL_MAX_MS, HEAL_BASE_MS * 2 ** heal.attempts);
250
- heal.attempts += 1;
251
- lensHeal.set(label, heal);
252
- console.warn(`[gateway] lens child ${label} exited (code=${code} sig=${sig}); auto-healing in ${Math.round(delay / 1000)}s (attempt ${heal.attempts}).`);
253
- await sleepUnref(delay);
254
- }
255
- if (rt.stopped() || removedLabels.has(label))
256
- return;
257
- // NEVER respawn into the dying child's port. A closing vite can hold its listener for seconds (open
258
- // HMR websockets), so the old fixed delay raced it → `listen EADDRINUSE` → a self-inflicted crash
259
- // that burned a heal attempt and LENGTHENED the 502. Bounded: past the budget we spawn anyway and
260
- // let the (now correctly-classified) heal take it from there.
261
- if (ownPort !== undefined && !(await waitForPortFree(ownPort, types_1.PORT_FREE_WAIT_MS, { isPortFree, intervalMs: types_1.PORT_FREE_POLL_MS }))) {
262
- console.warn(`[gateway] lens child ${label}: :${ownPort} still held after ${Math.round(types_1.PORT_FREE_WAIT_MS / 1000)}s — spawning anyway.`);
263
- }
264
- if (rt.stopped() || removedLabels.has(label))
265
- return;
266
- spawnManagedChild(label, bin, args, env, optional, respawn, cwd);
267
- })().catch(() => undefined); // a heal must never surface as an unhandled rejection
268
- });
269
- return child;
270
- };
271
- // LIVENESS probe for lens frontends: kill a vite that is ALIVE but NOT LISTENING (the permanent-502 wedge
272
- // — see LENS_PROBE_ENABLED). One cheap TCP connect per tracked child per tick; the pure `lensChildWedged`
273
- // owns the verdict (grace + N consecutive failures + the shared per-project cooldown), this owns the I/O.
274
- // A confirmed wedge gets SIGKILL — deliberately NOT SIGTERM: graceful shutdown is exactly what wedged (the
275
- // event loop is pinned by the lens WS server / bridge sockets), so a SIGTERM can hang and leave the app
276
- // 502ing forever. The kill lands on the proven exit→auto-heal path, which respawns after waiting the port
277
- // out. `probeInFlight` keeps a slow round from overlapping the next tick.
278
- let probeInFlight = false;
279
- const probeLensChildren = async (cfg, killWedged) => {
280
- if (probeInFlight)
281
- return;
282
- probeInFlight = true;
283
- try {
284
- for (const [child, meta] of [...viteChildren]) {
285
- if (rt.stopped())
286
- return;
287
- if (meta.port === undefined)
288
- continue; // unpinned port ⇒ nothing to probe
289
- if (child.exitCode !== null || child.signalCode !== null)
290
- continue; // already dying — the exit heal owns it
291
- // The probe now runs DURING the spawn grace too. It costs one extra TCP connect per child
292
- // for the first `graceMs`, and it buys the RECOVERY edge below at first-listen instead of
293
- // 45s later — the grace exists to withhold the KILL verdict from a booting vite, not to
294
- // withhold the observation that it came up.
295
- if (await probePortListening(meta.port, types_1.LENS_PROBE_TIMEOUT_MS)) {
296
- meta.probeFails = 0;
297
- // RECOVERY EDGE. A wedged/zombie-recycled vite is SIGKILLed and auto-healed by the exit
298
- // handler, which announces nothing — so `pod-recycle` (→ `wedged` in the lens fold) had no
299
- // counterpart and a lens app stayed "wedged" for the rest of the session while serving
300
- // every request (foodguard 2026-07-31: `wedged: ["authentication","dashboard"]`, `up: []`,
301
- // zero 502s). An accepting listener is the same evidence the wedge verdict is the absence
302
- // of, so it is what clears it. Once per generation: a fresh child starts `listening: false`.
303
- if (!meta.listening) {
304
- meta.listening = true;
305
- emit('info', `lens app up: ${meta.project} (vite :${meta.port} listening)`, `cluster-lens-app:${meta.project}`, { kind: 'service-up', project: meta.project, port: meta.port }, undefined, meta.bus);
306
- }
307
- continue;
308
- }
309
- if (Date.now() - meta.spawnedAt < cfg.graceMs) {
310
- meta.probeFails = 0;
311
- continue;
312
- } // still booting
313
- if (!killWedged)
314
- continue; // liveness still OBSERVED above; only the kill verdict is switched off
315
- meta.probeFails += 1;
316
- const reason = (0, lifecycle_1.lensChildWedged)(meta, viteRecycleAt.get(meta.project) ?? 0, Date.now(), cfg);
317
- if (!reason)
318
- continue; // failing, but not yet N-in-a-row / still in cooldown
319
- viteRecycleAt.set(meta.project, Date.now());
320
- meta.probeFails = 0;
321
- meta.listening = false; // re-arm the recovery edge, in case the SIGKILL below doesn't take
322
- console.error(`[gateway] wedge recycle: lens app ${meta.project} is ALIVE but NOT LISTENING on :${meta.port} after ${Math.max(2, cfg.failureThreshold)} probes — SIGKILL; a fresh vite is coming.`);
323
- emit('warning', `wedge recycle: ${meta.project} (${reason})`, `cluster-lens-app:${meta.project}`, { kind: 'pod-recycle', project: meta.project, reason, port: meta.port }, undefined, meta.bus);
324
- try {
325
- child.kill('SIGKILL');
326
- }
327
- catch { /* already gone — the exit auto-heal will respawn */ }
328
- }
329
- }
330
- finally {
331
- probeInFlight = false;
332
- }
333
- };
334
- // The lens-frontend sweeper drives BOTH self-heals over the tracked vite children:
335
- // 1. the LIVENESS probe above — alive but not listening (a permanent 502, invisible to the exit handler);
336
- // 2. ZOMBIE recycle — a vite whose fs-watcher went stale: ALIVE + reachable yet MISSING files created
337
- // after it spawned (500 `Failed to resolve import` for a file that exists; the reported 17.7h
338
- // dashboard pod). The shared `rt.sourceSetChangedAt` (stamped by the lifecycle sweeper's source-set
339
- // scan) drives it: a vite that spawned before a settled set change is killed → this manager's own
340
- // `on('exit')` auto-heal respawns a fresh vite that re-scans the FS. Only on a set CHANGE
341
- // (add/remove), never a content save, and never a blind max-age (no idle signal for a frontend).
342
- const startLensSweeper = () => {
343
- // Either heal alone justifies the sweeper: a user who disabled the staleness recycle (its churn was a
344
- // known problem) must still get a wedged 502 healed, so each branch is gated on its OWN switch.
345
- if (lensSweeper || (!types_1.POD_RECYCLE_ENABLED && !types_1.LENS_PROBE_ENABLED))
346
- return;
347
- const cfg = { graceMs: types_1.POD_STALE_GRACE_MS, settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS };
348
- lensSweeper = setInterval(() => {
349
- if (rt.stopped() || viteChildren.size === 0)
350
- return;
351
- // The liveness probe is INDEPENDENT of the source-set clock — a wedge has nothing to do with the file
352
- // set, so it must run before (and regardless of) the staleness early-return below. It runs whenever
353
- // the sweeper does: `LENS_PROBE_ENABLED` gates the KILL verdict, NOT the observation, because the
354
- // probe is also the only RECOVERY signal a lens frontend has — with the staleness recycle on and the
355
- // probe off, a zombie-recycled app would otherwise stay `wedged` in the fold forever.
356
- void probeLensFrontends();
357
- if (!types_1.POD_RECYCLE_ENABLED)
358
- return;
359
- const wideChangedAt = rt.sourceSetChangedAt ?? 0;
360
- if (wideChangedAt <= 0)
361
- return;
362
- const now = Date.now();
363
- for (const [child, meta] of viteChildren) {
364
- // Prefer this app's SCOPED clock — the file set within the subtrees ITS vite can resolve. On the wide
365
- // clock a backend-only `.ts` add anywhere in the monorepo restarted every frontend (`sourceScopeDirs`).
366
- const changedAt = rt.sourceSetChangedFor?.get(meta.project) ?? wideChangedAt;
367
- const last = viteRecycleAt.get(meta.project) ?? 0;
368
- if (!(0, lifecycle_1.lensFrontendStale)(meta.spawnedAt, last, changedAt, now, cfg))
369
- continue;
370
- viteRecycleAt.set(meta.project, now);
371
- meta.listening = false; // re-arm the recovery edge — the respawned vite must announce itself
372
- console.log(`[gateway] zombie recycle: lens app ${meta.project} (stale-source-set) — vite missed a file add; restarting it`);
373
- emit('warning', `zombie recycle: ${meta.project} (stale-source-set)`, `cluster-lens-app:${meta.project}`, { kind: 'pod-recycle', project: meta.project, reason: 'stale-source-set' }, undefined, meta.bus);
374
- try {
375
- child.kill('SIGTERM');
376
- }
377
- catch { /* already gone — the exit auto-heal will respawn */ }
378
- }
379
- }, Math.min(types_1.POD_STALE_SCAN_MS, 15_000));
380
- lensSweeper.unref?.();
381
- };
382
- const bootSingletonsAndApps = async () => {
383
- const lensApps = [...new Map(rt.routes.filter((r) => r.lens).map((r) => [r.project, r.lens])).values()];
384
- const wantDashboard = options.dashboard !== false;
385
- const wantMcp = options.mcp !== false && lensApps.length > 0;
386
- if (wantDashboard) {
387
- const dashboardBundle = (0, lens_frontend_1.findDashboardBundle)(rt.root);
388
- if (!dashboardBundle) {
389
- console.warn("[gateway] lens dashboard bundle not found — skipping. Install the self-contained CLI: `npm i -D lensmcp` (it ships `bundled/dashboard.js`), or `yarn nx build @lensmcp/lensmcp-mcp` in-repo.");
390
- }
391
- else {
392
- // Per-project lens scope: the dashboard is mounted at lensmcp.local/<key>/ so
393
- // MANY projects coexist (each at its own /<key> on its own port). The dashboard
394
- // OWNS its base path (LENSMCP_DASHBOARD_BASE) and history-routes under it, so the
395
- // gateway routes pass-through (matchRoute on the prefix; the proxy never strips).
396
- const scope = (0, scope_1.readLensScope)(rt.root);
397
- // The per-workspace scoped port (`.lensmcp/config.json` ports.dashboard) unless explicitly
398
- // overridden — then OS-probed: an orphaned dashboard from a crashed gateway may still hold it, and
399
- // routing there serves ANOTHER workspace's dashboard. Claiming also reserves it in-process so a
400
- // registered workspace's dashboard differs.
401
- const dashboardPort = await claimFreePort(options.dashboardPort ?? scope.dashboardPort, usedDashboardPorts);
402
- const lensHost = options.lensHost ?? 'lensmcp.local';
403
- const lensBase = scope.basePath; // e.g. /tetros
404
- // Pushed BEFORE the cert SANs are derived (routes.map(r=>r.host)), so lensmcp.local
405
- // auto-joins the TLS SANs. Run `gateway:trust` to add it to /etc/hosts.
406
- // Add this workspace's dashboard route to ITS fragment (not straight to rt.routes) so it survives a
407
- // registry rebuild when another workspace registers/unregisters, then rebuild the live merged table.
408
- rt.registry.appendRoutes(rt.wsKey, [{ host: lensHost, prefix: lensBase, project: observability_1.LENS_DASHBOARD_PROJECT, target: `http://localhost:${dashboardPort}` }]);
409
- rt.rebuildRoutes();
410
- // Publish this workspace to the GLOBAL registry so the root chooser at `${lensHost}/` lists it
411
- // (and marks it live while this gateway runs). Best-effort — a registry write never blocks serving.
412
- (0, workspace_registry_1.registerWorkspace)({ key: scope.key, root: rt.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
413
- console.log(`[gateway] starting lens dashboard → http://localhost:${dashboardPort} (routed at https://${lensHost}${lensBase}/)`);
414
- emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${dashboardPort}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port: dashboardPort, base: lensBase });
415
- spawnManagedChild('lens-dashboard', process.execPath, [dashboardBundle], {
416
- LENSMCP_EVENT_FILE: rt.eventFile,
417
- LENSMCP_DASHBOARD_PORT: String(dashboardPort),
418
- LENSMCP_DASHBOARD_BASE: lensBase,
419
- });
420
- }
421
- }
422
- if (wantMcp) {
423
- const mcpBundle = (0, lens_frontend_1.findMcpBundle)(rt.root);
424
- if (!mcpBundle) {
425
- console.warn("[gateway] MCP server bundle not found — skipping (lens apps will still publish to the bus; an external `lensmcp mcp` can read it). Install: `npm i -D lensmcp`.");
426
- }
427
- else {
428
- // The workspace's OWN port, not a shared default. This used to be a
429
- // hardcoded 3000 for EVERY workspace: with two gateways (or one daemon
430
- // hosting two workspaces) whichever bound first served the other's
431
- // sessions too — one project's agent reading another project's events.
432
- // The port is also the contract the MCP shim resolves from
433
- // `.lensmcp/config.json`, so it must not be stepped up on collision:
434
- // a shim pointed at 4543 cannot find a server that quietly moved to 4544.
435
- const scope = (0, scope_1.readLensScope)(rt.root);
436
- const mcpPort = Number(process.env['LENSMCP_PORT'] ?? scope.mcpHttpPort);
437
- console.log(`[gateway] starting lens MCP server on 127.0.0.1:${mcpPort} (${mcpBundle})`);
438
- spawnManagedChild('lens-mcp', process.execPath, [mcpBundle], {
439
- LENSMCP_EVENT_FILE: rt.eventFile,
440
- LENSMCP_TRANSPORT: process.env['LENSMCP_TRANSPORT'] ?? 'http',
441
- LENSMCP_PORT: String(mcpPort),
442
- // Which workspace this server serves — it publishes this on its identity
443
- // route so a shim can REFUSE it when two workspaces derive one port
444
- // (rather than silently reading the other project's events). Passed
445
- // explicitly because the child's cwd is ambient and inheritable, while
446
- // this is the workspace the gateway actually resolved the port from.
447
- LENSMCP_WORKSPACE_KEY: scope.key,
448
- LENSMCP_WORKSPACE_ROOT: rt.root,
449
- });
450
- }
451
- }
452
- // Spawn each `lens:true` frontend's Vite + browser capture as the upstream,
453
- // via the SAME shared helper `agent-dev` uses (singletons excluded — owned above).
454
- for (const app of lensApps) {
455
- if (app.started)
456
- continue;
457
- app.started = true;
458
- // Claim the app's port OS-probed (an orphaned vite from a crashed daemon may hold it — vite's
459
- // --strictPort would exit-loop while the route proxies into the stale one). On a step-up, point every
460
- // route of this project at the ACTUAL port (route objects are shared with the registry fragment, so
461
- // the rewrite survives rebuilds — mirrors hostWorkspaceApps).
462
- const appPort = await claimFreePort(app.port, usedAppPorts);
463
- if (appPort !== app.port) {
464
- for (const r of rt.routes) {
465
- if (r.lens?.project === app.project) {
466
- r.lens.port = appPort;
467
- r.target = `http://localhost:${appPort}`;
468
- }
469
- }
470
- app.port = appPort;
471
- }
472
- try {
473
- (0, lens_frontend_1.spawnLensFrontend)({
474
- projectRoot: app.projectRoot,
475
- workspaceRoot: rt.root,
476
- project: app.project,
477
- eventFile: rt.eventFile,
478
- port: app.port,
479
- chrome: true,
480
- headless: true,
481
- // Identify this app's events on the shared bus (mirrors the serve
482
- // executor's LENSMCP_PROJECT stamp for pods).
483
- viteEnv: { LENSMCP_PROJECT: app.project },
484
- log: (l) => console.log(l),
485
- }, spawnManagedChild);
486
- emit('info', `lens app started: ${app.project} (vite :${app.port})`, `cluster-lens-app:${app.project}`, { kind: 'lens-app-up', project: app.project, port: app.port });
487
- }
488
- catch (e) {
489
- console.error(`[gateway] lens app ${app.project}: ${e.message}`);
490
- }
491
- }
492
- startLensSweeper(); // watch the just-spawned lens vites for a stale-source-set zombie
493
- };
494
- // P3d — host a REGISTERED workspace's dashboard: its OWN events file, a distinct port, and a
495
- // `lensmcp.local/<key>` route on its fragment. The dashboard BUNDLE is the daemon's (rt.root); only the
496
- // events file + base + port are the registered workspace's. Returns a reaper the control endpoint calls
497
- // on unregister (marks the label removed so auto-heal won't respawn it, then kills the child).
498
- const hostWorkspaceDashboard = async (fragment) => {
499
- const bundle = (0, lens_frontend_1.findDashboardBundle)(rt.root);
500
- if (!bundle)
501
- return undefined;
502
- const scope = (0, scope_1.readLensScope)(fragment.root);
503
- const lensHost = options.lensHost ?? 'lensmcp.local';
504
- const lensBase = scope.basePath; // /<registered key>
505
- // Distinct from every other workspace's dashboard AND actually bindable (OS-probed for orphans).
506
- const port = await claimFreePort(scope.dashboardPort, usedDashboardPorts);
507
- const label = `lens-dashboard:${fragment.wsKey}`;
508
- const eventFile = path.join(fragment.root, '.lensmcp', 'events.jsonl'); // the registered workspace's OWN bus
509
- removedLabels.delete(label); // a fresh registration after a prior unregister → allow auto-heal again
510
- rt.registry.appendRoutes(fragment.wsKey, [{ host: lensHost, prefix: lensBase, project: observability_1.LENS_DASHBOARD_PROJECT, target: `http://localhost:${port}` }]);
511
- // Publish the hosted workspace to the GLOBAL chooser registry (keyed on the DAEMON's pid — the daemon is
512
- // what keeps it live), so `lensmcp.local/` lists it. Reaped on unregister below.
513
- (0, workspace_registry_1.registerWorkspace)({ key: fragment.wsKey, root: fragment.root, base: lensBase, pid: process.pid, procStamp: (0, workspace_registry_1.processStamp)(process.pid), updatedAt: new Date().toISOString() });
514
- console.log(`[gateway] hosting dashboard for workspace '${fragment.wsKey}' → http://localhost:${port} (routed at https://${lensHost}${lensBase}/)`);
515
- emit('info', `lens dashboard up: ${lensHost}${lensBase} → :${port}`, 'cluster-lens-dashboard', { kind: 'lens-dashboard-up', host: lensHost, port, base: lensBase });
516
- spawnManagedChild(label, process.execPath, [bundle], {
517
- LENSMCP_EVENT_FILE: eventFile,
518
- LENSMCP_DASHBOARD_PORT: String(port),
519
- LENSMCP_DASHBOARD_BASE: lensBase,
520
- LENSMCP_WS_KEY: fragment.wsKey,
521
- }, false, undefined,
522
- // The guest dashboard's Cluster view derives its service DECLS + service-keys from `process.cwd()`
523
- // (dashboard bundle: `const root = process.cwd()`), so the child MUST run in the REGISTERED
524
- // workspace's root — NOT the default `rt.root` (the daemon owner's). Without this a hosted workspace's
525
- // dashboard renders the DAEMON's `project.json` services (the "foodguard page shows tetros" bug); the
526
- // sibling `hostWorkspaceApps` already threads `fragment.root` this way.
527
- fragment.root);
528
- return () => {
529
- removedLabels.add(label); // stop auto-heal from resurrecting it
530
- usedDashboardPorts.delete(port);
531
- (0, workspace_registry_1.unregisterWorkspace)(fragment.wsKey); // drop it from the chooser registry
532
- try {
533
- labeledChildren.get(label)?.kill('SIGTERM');
534
- }
535
- catch { /* already gone */ }
536
- labeledChildren.delete(label);
537
- };
538
- };
539
- // P3d — host a REGISTERED workspace's `lens:true` FRONTEND apps (the model-A completeness gap: the daemon
540
- // spawned a registered workspace's pods on-demand + its dashboard, but NOT its lens frontends, so a route
541
- // like app.<key>.local pointed at a port nothing bound → 502). For each lens app: spawn its vite (+ browser
542
- // capture) in the registered workspace's OWN root + event bus, on a COLLISION-SAFE port (offset off the
543
- // daemon's own + other workspaces', since vite's --strictPort exits on a clash), and update the route
544
- // TARGET to that port so the merged table (rebuilt right after this in the control handler) upstreams
545
- // correctly. Unique labels (`<ws>:<proj>:vite`) keep auto-heal + the reaper from touching the daemon's own
546
- // identically-named vites. Returns a reaper the control endpoint calls on unregister.
547
- const hostWorkspaceApps = async (fragment) => {
548
- // Group a project's (possibly several host) routes so one spawned vite serves them all.
549
- const byProject = new Map();
550
- for (const r of fragment.routes) {
551
- if (!r.lens)
552
- continue;
553
- const list = byProject.get(r.lens.project) ?? [];
554
- list.push(r);
555
- byProject.set(r.lens.project, list);
556
- }
557
- if (byProject.size === 0)
558
- return () => { };
559
- const eventFile = path.join(fragment.root, '.lensmcp', 'events.jsonl'); // the registered workspace's OWN bus
560
- const labels = [];
561
- const ports = [];
562
- for (const [project, routes] of byProject) {
563
- const lens0 = routes[0].lens;
564
- if (lens0.started)
565
- continue;
566
- // Collision-safe port: keep the declared port unless already claimed in-process OR held at the OS
567
- // level (an orphaned vite from a crashed daemon), else step up.
568
- const port = await claimFreePort(lens0.port, usedAppPorts);
569
- ports.push(port);
570
- // Point every route of this project at the actual bound port (all share the one vite).
571
- for (const r of routes) {
572
- r.lens.started = true;
573
- r.lens.port = port;
574
- r.target = `http://localhost:${port}`;
575
- }
576
- const labelPrefix = `${fragment.wsKey}:${project}:`;
577
- const viteLabel = `${labelPrefix}vite`;
578
- const captureLabel = `${labelPrefix}browser-capture`;
579
- labels.push(viteLabel, captureLabel);
580
- removedLabels.delete(viteLabel); // a fresh registration after a prior unregister → allow auto-heal again
581
- removedLabels.delete(captureLabel);
582
- // A cwd-injecting spawn view: the registered workspace's children run in ITS root (not the daemon's).
583
- const spawnInRoot = (label, bin, args, env, optional, respawn) => spawnManagedChild(label, bin, args, env, optional, respawn, fragment.root);
584
- console.log(`[gateway] hosting lens app '${project}' of '${fragment.wsKey}' → vite :${port}`);
585
- try {
586
- (0, lens_frontend_1.spawnLensFrontend)({
587
- projectRoot: lens0.projectRoot,
588
- workspaceRoot: fragment.root,
589
- project,
590
- eventFile,
591
- port,
592
- chrome: true,
593
- headless: true,
594
- viteEnv: { LENSMCP_PROJECT: project },
595
- labelPrefix,
596
- log: (l) => console.log(l),
597
- }, spawnInRoot);
598
- // Onto the REGISTERED workspace's OWN bus (like the service layer's `emitSvc`): its cluster
599
- // view must show its apps, and the daemon's must not — two workspaces can both own a
600
- // project called `dashboard`, so a shared bus makes the fold ambiguous as well as wrong.
601
- emit('info', `lens app started: ${fragment.wsKey}/${project} (vite :${port})`, `cluster-lens-app:${fragment.wsKey}:${project}`, { kind: 'lens-app-up', project, port }, undefined, eventFile);
602
- }
603
- catch (e) {
604
- console.error(`[gateway] lens app ${fragment.wsKey}/${project}: ${e.message}`);
605
- }
606
- }
607
- return () => {
608
- for (const label of labels) {
609
- removedLabels.add(label); // stop auto-heal resurrecting it
610
- try {
611
- labeledChildren.get(label)?.kill('SIGTERM');
612
- }
613
- catch { /* already gone */ }
614
- labeledChildren.delete(label);
615
- }
616
- for (const p of ports)
617
- usedAppPorts.delete(p);
618
- };
619
- };
620
- const reapAll = async (graceMs = 2500) => {
621
- if (lensSweeper) {
622
- clearInterval(lensSweeper);
623
- lensSweeper = undefined;
624
- }
625
- const kids = [...lensChildren];
626
- for (const c of kids) {
627
- try {
628
- c.kill('SIGTERM');
629
- }
630
- catch { /* already gone */ }
631
- }
632
- // Grace, then SIGKILL any survivor — vite ignores SIGTERM, so without this it ORPHANS (reparents to
633
- // ppid 1) when the daemon exits, keeping its port held (5173/4200/…). Poll so we escalate only for the
634
- // stragglers and wait no longer than needed.
635
- const alive = (c) => c.exitCode === null && c.signalCode === null;
636
- const deadline = Date.now() + graceMs;
637
- while (Date.now() < deadline && kids.some(alive)) {
638
- await new Promise((r) => setTimeout(r, 100));
639
- }
640
- for (const c of kids) {
641
- if (alive(c)) {
642
- try {
643
- c.kill('SIGKILL');
644
- }
645
- catch { /* already gone */ }
646
- }
647
- }
648
- };
649
- const probeLensFrontends = () => probeLensChildren({ graceMs: types_1.LENS_PROBE_GRACE_MS, failureThreshold: types_1.LENS_PROBE_FAILS, cooldownMs: types_1.LENS_PROBE_COOLDOWN_MS }, types_1.LENS_PROBE_ENABLED);
650
- return { spawnManagedChild, bootSingletonsAndApps, hostWorkspaceDashboard, hostWorkspaceApps, probeLensFrontends, reapAll };
651
- }
1
+ "use strict";var V=Object.defineProperty;var L=(t,c)=>V(t,"name",{value:c,configurable:!0});var G=Object.defineProperty,i=L((t,c)=>G(t,"name",{value:c,configurable:!0}),"i");Object.defineProperty(exports,"__esModule",{value:!0}),exports.probePortFree=probePortFree,exports.probePortListening=probePortListening,exports.waitForPortFree=waitForPortFree,exports.viteArgPort=viteArgPort,exports.createLensChildren=createLensChildren;const tslib_1=require("tslib"),net=tslib_1.__importStar(require("node:net")),path=tslib_1.__importStar(require("node:path")),node_child_process_1=require("node:child_process"),lens_frontend_1=require("@lensmcp/nx-plugin/lens-frontend"),pod_env_1=require("./pod-env"),scope_1=require("./scope"),workspace_registry_1=require("./workspace-registry"),lifecycle_1=require("./lifecycle"),types_1=require("./types"),observability_1=require("./observability"),HEAL_BASE_MS=1500,HEAL_MAX_MS=3e4,HEAL_HEALTHY_MS=2e4;function tryBind(t,c){return new Promise(p=>{const h=net.createServer();h.unref(),h.once("error",E=>{const _=E.code??"";p(_==="EADDRINUSE"||_==="EACCES"?"in-use":"unavailable")}),h.listen(c===void 0?{port:t}:{port:t,host:c},()=>h.close(()=>p("free")))})}L(tryBind,"tryBind"),i(tryBind,"tryBind");async function probePortFree(t){for(const c of[void 0,"127.0.0.1","::1"])if(await tryBind(t,c)==="in-use")return!1;return!0}L(probePortFree,"probePortFree"),i(probePortFree,"probePortFree");function probeConnect(t,c,p){return new Promise(h=>{let E=!1;const _=net.createConnection({host:t,port:c});_.unref();const g=setTimeout(()=>b(!1),p);g.unref?.();function b(M){E||(E=!0,clearTimeout(g),_.destroy(),h(M))}L(b,"g"),i(b,"finish"),_.once("connect",()=>b(!0)),_.once("error",()=>b(!1))})}L(probeConnect,"probeConnect"),i(probeConnect,"probeConnect");async function probePortListening(t,c=1e3){return await probeConnect("127.0.0.1",t,c)?!0:probeConnect("::1",t,c)}L(probePortListening,"probePortListening"),i(probePortListening,"probePortListening");const sleepUnref=i(t=>new Promise(c=>{setTimeout(c,t).unref?.()}),"sleepUnref");async function waitForPortFree(t,c,p={}){const h=p.isPortFree??probePortFree,E=Math.max(1,p.intervalMs??types_1.PORT_FREE_POLL_MS),_=Date.now()+Math.max(0,c);for(;;){if(await h(t))return!0;const g=_-Date.now();if(g<=0)return!1;await sleepUnref(Math.min(E,g))}}L(waitForPortFree,"waitForPortFree"),i(waitForPortFree,"waitForPortFree");function viteArgPort(t){const c=i(p=>Number.isInteger(p)&&p>0&&p<65536?p:void 0,"valid");for(let p=0;p<t.length;p+=1){const h=t[p];if(h==="--port")return c(Number(t[p+1]));if(h.startsWith("--port="))return c(Number(h.slice(7)))}}L(viteArgPort,"viteArgPort"),i(viteArgPort,"viteArgPort");function createLensChildren(t,c,p,h){const{emit:E}=c,_=h?.isPortFree??probePortFree,g=[],b=new Map,M=new Map,A=new Map;let R;const v=new Map,y=new Set,j=new Set,D=new Set,m=i(async(o,a)=>{let s=o;for(;a.has(s)||!await _(s);)s+=1;return a.add(s),s!==o&&console.warn(`[gateway] port ${o} is unavailable (another process holds it \u2014 an orphan from a crashed gateway?) \u2014 using ${s} instead.`),s},"claimFreePort"),C=i((o,a,s,e,r=!1,n,l)=>{const d=Date.now(),u=(0,node_child_process_1.spawn)(a,s,{cwd:l??t.root,env:{...(0,pod_env_1.podBaseEnv)(process.env,t.root,l??t.root),...e??{},LENSMCP_PARENT_PID:String(process.pid)},stdio:"inherit"});g.push(u),v.set(o,u);const $=o==="vite"||o.endsWith(":vite"),w=$?viteArgPort(s):void 0;if($){const P=typeof e?.LENSMCP_EVENT_FILE=="string"?e.LENSMCP_EVENT_FILE:void 0;M.set(u,{project:e?.LENSMCP_PROJECT??"app",spawnedAt:d,port:w,probeFails:0,listening:!1,...P&&P!==t.eventFile?{bus:P}:{}})}return u.on("exit",(P,O)=>{const f=g.indexOf(u);if(f>=0&&g.splice(f,1),M.delete(u),t.stopped()||y.has(o))return;const T=Date.now()-d,S=b.get(o)??{attempts:0,portConflicts:0};(async()=>{const I=w!==void 0&&!await _(w);if((0,lifecycle_1.isPortConflictExit)({code:P,signal:O,ranMs:T,portHeld:I},{fastFailMs:types_1.CHILD_START_FAIL_MS})&&S.portConflicts<types_1.MAX_PORT_CONFLICT_RETRIES)S.portConflicts+=1,b.set(o,S),console.warn(`[gateway] lens child ${o} lost the :${w} bind race (EADDRINUSE, code=${P}); waiting for the port, then retrying (port-retry ${S.portConflicts}/${types_1.MAX_PORT_CONFLICT_RETRIES}; crash budget untouched).`);else{S.portConflicts=0,T>=HEAL_HEALTHY_MS&&(S.attempts=0);const N=Math.min(HEAL_MAX_MS,HEAL_BASE_MS*2**S.attempts);S.attempts+=1,b.set(o,S),console.warn(`[gateway] lens child ${o} exited (code=${P} sig=${O}); auto-healing in ${Math.round(N/1e3)}s (attempt ${S.attempts}).`),await sleepUnref(N)}t.stopped()||y.has(o)||(w!==void 0&&!await waitForPortFree(w,types_1.PORT_FREE_WAIT_MS,{isPortFree:_,intervalMs:types_1.PORT_FREE_POLL_MS})&&console.warn(`[gateway] lens child ${o}: :${w} still held after ${Math.round(types_1.PORT_FREE_WAIT_MS/1e3)}s \u2014 spawning anyway.`),!(t.stopped()||y.has(o))&&C(o,a,s,e,r,n,l))})().catch(()=>{})}),u},"spawnManagedChild");let F=!1;const B=i(async(o,a)=>{if(!F){F=!0;try{for(const[s,e]of[...M]){if(t.stopped())return;if(e.port===void 0||s.exitCode!==null||s.signalCode!==null)continue;if(await probePortListening(e.port,types_1.LENS_PROBE_TIMEOUT_MS)){e.probeFails=0,e.listening||(e.listening=!0,E("info",`lens app up: ${e.project} (vite :${e.port} listening)`,`cluster-lens-app:${e.project}`,{kind:"service-up",project:e.project,port:e.port},void 0,e.bus));continue}if(Date.now()-e.spawnedAt<o.graceMs){e.probeFails=0;continue}if(!a)continue;e.probeFails+=1;const r=(0,lifecycle_1.lensChildWedged)(e,A.get(e.project)??0,Date.now(),o);if(r){A.set(e.project,Date.now()),e.probeFails=0,e.listening=!1,console.error(`[gateway] wedge recycle: lens app ${e.project} is ALIVE but NOT LISTENING on :${e.port} after ${Math.max(2,o.failureThreshold)} probes \u2014 SIGKILL; a fresh vite is coming.`),E("warning",`wedge recycle: ${e.project} (${r})`,`cluster-lens-app:${e.project}`,{kind:"pod-recycle",project:e.project,reason:r,port:e.port},void 0,e.bus);try{s.kill("SIGKILL")}catch{}}}}finally{F=!1}}},"probeLensChildren"),x=i(()=>{if(R||!types_1.POD_RECYCLE_ENABLED&&!types_1.LENS_PROBE_ENABLED)return;const o={graceMs:types_1.POD_STALE_GRACE_MS,settleMs:types_1.POD_RECYCLE_SETTLE_MS,cooldownMs:types_1.POD_STALE_RECYCLE_COOLDOWN_MS};R=setInterval(()=>{if(t.stopped()||M.size===0||(k(),!types_1.POD_RECYCLE_ENABLED))return;const a=t.sourceSetChangedAt??0;if(a<=0)return;const s=Date.now();for(const[e,r]of M){const n=t.sourceSetChangedFor?.get(r.project)??a,l=A.get(r.project)??0;if((0,lifecycle_1.lensFrontendStale)(r.spawnedAt,l,n,s,o)){A.set(r.project,s),r.listening=!1,console.log(`[gateway] zombie recycle: lens app ${r.project} (stale-source-set) \u2014 vite missed a file add; restarting it`),E("warning",`zombie recycle: ${r.project} (stale-source-set)`,`cluster-lens-app:${r.project}`,{kind:"pod-recycle",project:r.project,reason:"stale-source-set"},void 0,r.bus);try{e.kill("SIGTERM")}catch{}}}},Math.min(types_1.POD_STALE_SCAN_MS,15e3)),R.unref?.()},"startLensSweeper"),K=i(async()=>{const o=[...new Map(t.routes.filter(e=>e.lens).map(e=>[e.project,e.lens])).values()],a=p.dashboard!==!1,s=p.mcp!==!1&&o.length>0;if(a){const e=(0,lens_frontend_1.findDashboardBundle)(t.root);if(!e)console.warn("[gateway] lens dashboard bundle not found \u2014 skipping. Install the self-contained CLI: `npm i -D lensmcp` (it ships `bundled/dashboard.js`), or `yarn nx build @lensmcp/lensmcp-mcp` in-repo.");else{const r=(0,scope_1.readLensScope)(t.root),n=await m(p.dashboardPort??r.dashboardPort,j),l=p.lensHost??"lensmcp.local",d=r.basePath;t.registry.appendRoutes(t.wsKey,[{host:l,prefix:d,project:observability_1.LENS_DASHBOARD_PROJECT,target:`http://localhost:${n}`}]),t.rebuildRoutes(),(0,workspace_registry_1.registerWorkspace)({key:r.key,root:t.root,base:d,pid:process.pid,procStamp:(0,workspace_registry_1.processStamp)(process.pid),updatedAt:new Date().toISOString()}),console.log(`[gateway] starting lens dashboard \u2192 http://localhost:${n} (routed at https://${l}${d}/)`),E("info",`lens dashboard up: ${l}${d} \u2192 :${n}`,"cluster-lens-dashboard",{kind:"lens-dashboard-up",host:l,port:n,base:d}),C("lens-dashboard",process.execPath,[e],{LENSMCP_EVENT_FILE:t.eventFile,LENSMCP_DASHBOARD_PORT:String(n),LENSMCP_DASHBOARD_BASE:d})}}if(s){const e=(0,lens_frontend_1.findMcpBundle)(t.root);if(!e)console.warn("[gateway] MCP server bundle not found \u2014 skipping (lens apps will still publish to the bus; an external `lensmcp mcp` can read it). Install: `npm i -D lensmcp`.");else{const r=(0,scope_1.readLensScope)(t.root),n=Number(process.env.LENSMCP_PORT??r.mcpHttpPort);console.log(`[gateway] starting lens MCP server on 127.0.0.1:${n} (${e})`),C("lens-mcp",process.execPath,[e],{LENSMCP_EVENT_FILE:t.eventFile,LENSMCP_TRANSPORT:process.env.LENSMCP_TRANSPORT??"http",LENSMCP_PORT:String(n),LENSMCP_WORKSPACE_KEY:r.key,LENSMCP_WORKSPACE_ROOT:t.root})}}for(const e of o){if(e.started)continue;e.started=!0;const r=await m(e.port,D);if(r!==e.port){for(const n of t.routes)n.lens?.project===e.project&&(n.lens.port=r,n.target=`http://localhost:${r}`);e.port=r}try{(0,lens_frontend_1.spawnLensFrontend)({projectRoot:e.projectRoot,workspaceRoot:t.root,project:e.project,eventFile:t.eventFile,port:e.port,chrome:!0,headless:!0,viteEnv:{LENSMCP_PROJECT:e.project},log:i(n=>console.log(n),"log")},C),E("info",`lens app started: ${e.project} (vite :${e.port})`,`cluster-lens-app:${e.project}`,{kind:"lens-app-up",project:e.project,port:e.port})}catch(n){console.error(`[gateway] lens app ${e.project}: ${n.message}`)}}x()},"bootSingletonsAndApps"),W=i(async o=>{const a=(0,lens_frontend_1.findDashboardBundle)(t.root);if(!a)return;const s=(0,scope_1.readLensScope)(o.root),e=p.lensHost??"lensmcp.local",r=s.basePath,n=await m(s.dashboardPort,j),l=`lens-dashboard:${o.wsKey}`,d=path.join(o.root,".lensmcp","events.jsonl");return y.delete(l),t.registry.appendRoutes(o.wsKey,[{host:e,prefix:r,project:observability_1.LENS_DASHBOARD_PROJECT,target:`http://localhost:${n}`}]),(0,workspace_registry_1.registerWorkspace)({key:o.wsKey,root:o.root,base:r,pid:process.pid,procStamp:(0,workspace_registry_1.processStamp)(process.pid),updatedAt:new Date().toISOString()}),console.log(`[gateway] hosting dashboard for workspace '${o.wsKey}' \u2192 http://localhost:${n} (routed at https://${e}${r}/)`),E("info",`lens dashboard up: ${e}${r} \u2192 :${n}`,"cluster-lens-dashboard",{kind:"lens-dashboard-up",host:e,port:n,base:r}),C(l,process.execPath,[a],{LENSMCP_EVENT_FILE:d,LENSMCP_DASHBOARD_PORT:String(n),LENSMCP_DASHBOARD_BASE:r,LENSMCP_WS_KEY:o.wsKey},!1,void 0,o.root),()=>{y.add(l),j.delete(n),(0,workspace_registry_1.unregisterWorkspace)(o.wsKey);try{v.get(l)?.kill("SIGTERM")}catch{}v.delete(l)}},"hostWorkspaceDashboard"),H=i(async o=>{const a=new Map;for(const n of o.routes){if(!n.lens)continue;const l=a.get(n.lens.project)??[];l.push(n),a.set(n.lens.project,l)}if(a.size===0)return()=>{};const s=path.join(o.root,".lensmcp","events.jsonl"),e=[],r=[];for(const[n,l]of a){const d=l[0].lens;if(d.started)continue;const u=await m(d.port,D);r.push(u);for(const f of l)f.lens.started=!0,f.lens.port=u,f.target=`http://localhost:${u}`;const $=`${o.wsKey}:${n}:`,w=`${$}vite`,P=`${$}browser-capture`;e.push(w,P),y.delete(w),y.delete(P);const O=i((f,T,S,I,N,Y)=>C(f,T,S,I,N,Y,o.root),"spawnInRoot");console.log(`[gateway] hosting lens app '${n}' of '${o.wsKey}' \u2192 vite :${u}`);try{(0,lens_frontend_1.spawnLensFrontend)({projectRoot:d.projectRoot,workspaceRoot:o.root,project:n,eventFile:s,port:u,chrome:!0,headless:!0,viteEnv:{LENSMCP_PROJECT:n},labelPrefix:$,log:i(f=>console.log(f),"log")},O),E("info",`lens app started: ${o.wsKey}/${n} (vite :${u})`,`cluster-lens-app:${o.wsKey}:${n}`,{kind:"lens-app-up",project:n,port:u},void 0,s)}catch(f){console.error(`[gateway] lens app ${o.wsKey}/${n}: ${f.message}`)}}return()=>{for(const n of e){y.add(n);try{v.get(n)?.kill("SIGTERM")}catch{}v.delete(n)}for(const n of r)D.delete(n)}},"hostWorkspaceApps"),q=i(async(o=2500)=>{R&&(clearInterval(R),R=void 0);const a=[...g];for(const r of a)try{r.kill("SIGTERM")}catch{}const s=i(r=>r.exitCode===null&&r.signalCode===null,"alive"),e=Date.now()+o;for(;Date.now()<e&&a.some(s);)await new Promise(r=>setTimeout(r,100));for(const r of a)if(s(r))try{r.kill("SIGKILL")}catch{}},"reapAll"),k=i(()=>B({graceMs:types_1.LENS_PROBE_GRACE_MS,failureThreshold:types_1.LENS_PROBE_FAILS,cooldownMs:types_1.LENS_PROBE_COOLDOWN_MS},types_1.LENS_PROBE_ENABLED),"probeLensFrontends");return{spawnManagedChild:C,bootSingletonsAndApps:K,hostWorkspaceDashboard:W,hostWorkspaceApps:H,probeLensFrontends:k,reapAll:q}}L(createLensChildren,"createLensChildren"),i(createLensChildren,"createLensChildren");