@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,842 +1,3 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.sourceSetSignature = sourceSetSignature;
4
- exports.sourceScopeDirs = sourceScopeDirs;
5
- exports.podRecycleReason = podRecycleReason;
6
- exports.lensFrontendStale = lensFrontendStale;
7
- exports.lensChildWedged = lensChildWedged;
8
- exports.isPortConflictExit = isPortConflictExit;
9
- exports.createServiceLayer = createServiceLayer;
10
- const tslib_1 = require("tslib");
11
- /**
12
- * Service lifecycle: scale-from-zero, autoscale, idle kill. The gateway is the
13
- * load balancer in front of each pod service's unix-socket pool and owns the
14
- * pods' serverless lifecycle (eager/minPods/maxPods/idleKillSec).
15
- */
16
- const node_child_process_1 = require("node:child_process");
17
- const fs = tslib_1.__importStar(require("node:fs"));
18
- const http = tslib_1.__importStar(require("node:http"));
19
- const path = tslib_1.__importStar(require("node:path"));
20
- const node_util_1 = require("node:util");
21
- const discovery_1 = require("./discovery");
22
- const scope_1 = require("./scope");
23
- const pod_env_1 = require("./pod-env");
24
- const types_1 = require("./types");
25
- /**
26
- * The east-west env a pod needs to call ANOTHER service's `internal.<host>` route (memberships, settings,
27
- * plan): (1) its OWN per-service key — presented as `x-api-key`; the gateway maps it back to the caller and
28
- * stamps `x-api-key-id` (a service can't forge another's identity). (2) `NODE_EXTRA_CA_CERTS` = the
29
- * MACHINE-LEVEL dev CA (`~/.lensmcp/ca`), so the pod's OUTBOUND `fetch('https://internal.<host>')` clears
30
- * TLS — and keeps clearing it across daemon handovers, since every daemon on the machine signs with that
31
- * same anchor (a per-workspace path here once broke pods that outlived a guest→own-daemon switch). Without
32
- * these the call fails (connect/TLS or a gateway 401) and tenant-token minting can't resolve the role.
33
- * NOT `INTERNAL_API_KEY`/`LENSMCP_INTERNAL_TOKEN` (those would flip ON the inbound gateway-trust guard) —
34
- * a DEDICATED name a service opts into for OUTBOUND only, leaving inbound dev-standalone behavior unchanged.
35
- */
36
- function eastWestEnv(rt, project) {
37
- const env = {};
38
- const key = rt.serviceKeys[project];
39
- if (key)
40
- env.LENSMCP_SERVICE_KEY = key;
41
- if (rt.https) {
42
- // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy like server.ts (cert deps)
43
- const basicSsl = require('../../../basic-ssl');
44
- const caPath = basicSsl.caCertPath();
45
- if (fs.existsSync(caPath))
46
- env.NODE_EXTRA_CA_CERTS = caPath;
47
- }
48
- return env;
49
- }
50
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
51
- // --- zombie-pod recycle: source-file-SET signature -------------------------------------------------------
52
- // A pod goes "stale" when the set of source files CHANGES (a file added/removed) after it spawned and its
53
- // degraded watcher missed it. We detect a set change — NOT a content change — by a cheap order-independent
54
- // signature over source file PATHS (count + xor of per-path hashes): adding/removing a path flips the
55
- // signature; editing a file's CONTENTS does not, so ordinary saves never trigger a recycle.
56
- // RUNTIME OUTPUT IS NOT SOURCE. The hardcoded list below can only ever name the conventional output dirs;
57
- // a workspace whose app WRITES into some other gitignored dir under the root would otherwise read every
58
- // write as "a dev added a source file" and recycle every pod. Worked failure (foodguard, 2026-07-28): the
59
- // engine's local artifact store defaults to `./artifacts` at the workspace root, so each run's
60
- // `publish_pack` added pack-manifest + source-snapshot `.json` → the set flipped → the sweeper SIGTERMed all
61
- // four vite pods → a ~12-18s `gateway.unavailable` 502 window per run (201 dashboard recycles in one
62
- // session, and it crossed workspaces: the sweeper's viteChildren span every REGISTERED workspace while the
63
- // scan only walks the daemon host's root). So the set is filtered by git's OWN ignore verdict too.
64
- const SRC_EXCLUDE = new Set(['node_modules', 'dist', 'tmp', 'coverage', 'build', 'out', 'target']);
65
- const SRC_EXT = new Set([
66
- '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.css', '.scss', '.sass', '.less',
67
- '.json', '.html', '.htm', '.vue', '.svelte', '.mdx',
68
- ]);
69
- const SCAN_FILE_CAP = 100_000;
70
- function fnv1a(s) {
71
- let h = 2166136261;
72
- for (let i = 0; i < s.length; i += 1) {
73
- h ^= s.charCodeAt(i);
74
- h = Math.imul(h, 16777619);
75
- }
76
- return h >>> 0;
77
- }
78
- const execFileP = (0, node_util_1.promisify)(node_child_process_1.execFile);
79
- /** The repo-relative paths git IGNORES — directories carry a trailing `/` (git's `--directory` collapses a
80
- * wholly-ignored dir), files do not. ONE `git ls-files` per scan (~80ms on a 2.6k-file workspace, off the
81
- * event loop) buys exact gitignore semantics — nested `.gitignore`, `.git/info/exclude`, global excludes —
82
- * with no new dependency and no hand-rolled glob matcher. A partially-ignored dir isn't collapsed, so its
83
- * individual ignored files are listed and filtered per-file instead. Fails OPEN to an empty set (git absent,
84
- * not a repo, timeout) → the hardcoded `SRC_EXCLUDE` list alone still applies, exactly as before. */
85
- async function gitIgnoredPaths(root) {
86
- try {
87
- const { stdout } = await execFileP('git', ['ls-files', '--others', '--directory', '--ignored', '--exclude-standard', '-z'], { cwd: root, maxBuffer: 32 * 1024 * 1024, timeout: 10_000, windowsHide: true });
88
- return new Set(stdout.split('\0').filter(Boolean)); // -z → NUL-separated, never git's quoted-path form
89
- }
90
- catch {
91
- return new Set();
92
- }
93
- }
94
- /** Bounded, async, non-blocking walk of the workspace source tree → a signature of the file SET
95
- * (`<count>:<xor-of-path-hashes>`). Skips node_modules/dist/build/dotdirs + `*-devserver` sock dirs + every
96
- * GITIGNORED path (runtime output is not source — see `SRC_EXCLUDE`), and only counts source-extension
97
- * files. Order-independent (readdir order irrelevant) and null-safe.
98
- *
99
- * `scopeDirs` (absolute, from {@link sourceScopeDirs}) narrows the walk to the subtrees a given child could
100
- * actually resolve, instead of the whole monorepo — see that function for WHY. Omitted ⇒ the whole root
101
- * (the original workspace-wide behavior, still used when a child's project root is unknown). Root-level
102
- * loose files are always counted, so a `package.json`/`tsconfig.base.json` add reaches every scope. */
103
- async function sourceSetSignature(root, scopeDirs, extraExclude) {
104
- let count = 0;
105
- let xor = 0;
106
- const ignored = await gitIgnoredPaths(root);
107
- // git speaks posix-relative; `path.relative` yields `\` on win32 — normalize before every lookup.
108
- const rel = (abs) => path.relative(root, abs).split(path.sep).join('/');
109
- const walk = async (dir, recurse = true) => {
110
- if (count > SCAN_FILE_CAP)
111
- return;
112
- let entries;
113
- try {
114
- entries = await fs.promises.readdir(dir, { withFileTypes: true });
115
- }
116
- catch {
117
- return;
118
- }
119
- for (const e of entries) {
120
- if (count > SCAN_FILE_CAP)
121
- return;
122
- const name = e.name;
123
- const abs = path.join(dir, name);
124
- if (e.isDirectory()) {
125
- if (!recurse)
126
- continue; // scoped mode: root's own dirs are entered only via scopeDirs
127
- if (name.startsWith('.') || SRC_EXCLUDE.has(name) || extraExclude?.has(name) || name.endsWith('-devserver'))
128
- continue;
129
- if (ignored.has(`${rel(abs)}/`))
130
- continue; // a gitignored output dir (foodguard's `artifacts/`)
131
- await walk(abs);
132
- }
133
- else if (e.isFile()) {
134
- const dot = name.lastIndexOf('.');
135
- if (dot <= 0 || !SRC_EXT.has(name.slice(dot)))
136
- continue;
137
- if (ignored.has(rel(abs)))
138
- continue; // a gitignored file in a tracked dir (generated reports)
139
- count += 1;
140
- xor ^= fnv1a(abs);
141
- }
142
- }
143
- };
144
- if (scopeDirs && scopeDirs.length > 0) {
145
- await walk(root, false); // root-level loose files only — its subdirs come from scopeDirs
146
- // Sequential, not Promise.all: `count`/`xor` are shared accumulators, and the xor is
147
- // order-independent anyway — so concurrency would buy nothing but interleaving risk.
148
- for (const dir of scopeDirs)
149
- await walk(dir);
150
- }
151
- else {
152
- await walk(root);
153
- }
154
- return `${count}:${(xor >>> 0).toString(36)}`;
155
- }
156
- /**
157
- * PURE: which subtrees a child of `projectRoot` must watch for a "file set changed" verdict.
158
- *
159
- * The staleness signal exists to catch a vite/pod whose fs-watcher MISSED a file add. But the signature was
160
- * workspace-WIDE while its consumers are PER-CHILD, so adding a backend-only `server/apps/auth/**\/*.ts`
161
- * — a file in no vite module graph — recycled every frontend vite too. Worked failure (foodguard,
162
- * 2026-07-28): one session writing `server/apps/auth/src/modules/totp/**` added a `.ts` every ~15-20s and
163
- * held `app.foodguard.local` in near-continuous 502s, 951 recycles in a session, none of them relevant to
164
- * the dashboard.
165
- *
166
- * The scope is the child's OWN top-level bucket plus every bucket that hosts no OTHER gateway-managed
167
- * project (`managedBuckets`). In a scope-first monorepo (`web/ server/ shared/ ai/ tools/`) that yields
168
- * `web + shared + ai + tools` for a frontend and `server + shared + ai + tools` for a service: a
169
- * `server/**` add no longer touches a frontend, while a `shared/contracts` add still reaches both — which
170
- * is correct, since both import it. It deliberately OVER-includes (a frontend watches `ai/` it may never
171
- * import) because a false recycle costs a cold start whereas a MISSED one costs a wedged dev server.
172
- *
173
- * Both args are absolute; the result is absolute and deduped. An empty result (the project root IS the
174
- * workspace root, or it sits outside it) means "no narrowing" → the caller falls back to the whole root.
175
- */
176
- function sourceScopeDirs(workspaceRoot, projectRoot, managedBuckets, topLevelDirs, extraExclude) {
177
- const bucketOf = (abs) => {
178
- const r = path.relative(workspaceRoot, abs).split(path.sep).filter(Boolean);
179
- return r.length === 0 || r[0].startsWith('..') ? '' : r[0];
180
- };
181
- const own = bucketOf(projectRoot);
182
- if (!own)
183
- return []; // project root === workspace root (or outside it) → cannot narrow
184
- const managed = new Set(managedBuckets.filter(Boolean));
185
- const keep = new Set([own]);
186
- for (const d of topLevelDirs) {
187
- if (d.startsWith('.') || SRC_EXCLUDE.has(d) || extraExclude?.has(d) || d.endsWith('-devserver'))
188
- continue;
189
- if (!managed.has(d))
190
- keep.add(d); // a bucket owned by no managed project = shared/leaf → always watch
191
- }
192
- return [...keep].sort().map((d) => path.join(workspaceRoot, d));
193
- }
194
- /**
195
- * PURE decision: should this pod be recycled as a stale-view zombie, and why? A pod qualifies only when it
196
- * is a live, UP, gateway-spawned pod that is currently QUIET (0 in-flight AND idle for `settleMs`, so a busy
197
- * pod is never interrupted) and past its per-service cooldown. Then it recycles if the source file SET
198
- * changed after it spawned (`stale-source-set`) or as a plain age backstop (`max-age`). Returns `null` when
199
- * it must be left alone. Kept pure (no `ServiceCtl` mutation, no fs) so the sweeper's logic is unit-tested
200
- * without spawning a process. `sourceSetChangedAt` is 0 until the gateway first observes a set change.
201
- */
202
- function podRecycleReason(svc, now, sourceSetChangedAt, cfg) {
203
- if (!svc.proc || svc.state !== 'up' || svc.spawnAt == null)
204
- return null; // only a live, up, spawned pod
205
- if (svc.inflight > 0)
206
- return null; // never mid-request
207
- if (now - svc.lastUsed < cfg.settleMs)
208
- return null; // wait for an inter-request lull
209
- if (svc.lastStaleRecycleAt != null && now - svc.lastStaleRecycleAt < cfg.cooldownMs)
210
- return null; // anti-thrash
211
- if (sourceSetChangedAt > 0 && sourceSetChangedAt - svc.spawnAt > cfg.graceMs)
212
- return 'stale-source-set';
213
- if (cfg.maxAgeMs > 0 && now - svc.spawnAt > cfg.maxAgeMs)
214
- return 'max-age';
215
- return null;
216
- }
217
- /**
218
- * PURE decision for a LENS-FRONTEND vite child (a `lens:true` app's dev server — the reported zombie). It
219
- * has no in-flight/idle signal like a pod, so it recycles ONLY on a settled source-file-SET change that
220
- * POST-dates its spawn (the precise "watcher missed a new file" trigger) — never a blind max-age (which
221
- * could interrupt active work). `killing` it lets the manager's `on('exit')` auto-heal respawn a fresh vite
222
- * that re-scans the FS. Kept pure + exported so the recycle logic is unit-tested without a process.
223
- */
224
- function lensFrontendStale(spawnedAt, lastRecycleAt, sourceSetChangedAt, now, cfg) {
225
- if (now - lastRecycleAt < cfg.cooldownMs)
226
- return false; // anti-thrash on a source-churn burst
227
- if (sourceSetChangedAt <= 0)
228
- return false; // no set change observed yet
229
- if (sourceSetChangedAt - spawnedAt <= cfg.graceMs)
230
- return false; // this vite already postdates the change
231
- if (now - sourceSetChangedAt < cfg.settleMs)
232
- return false; // let an add/remove burst settle → one recycle
233
- return true;
234
- }
235
- /**
236
- * PURE decision: is this ALIVE lens-frontend child WEDGED — process running, port NOT listening?
237
- *
238
- * The third self-heal blind spot. `lensFrontendStale` catches a vite serving a stale FS view; the exit
239
- * handler catches a vite that DIED. Neither sees a vite that is alive, has printed `VITE ready`, and has
240
- * NO LISTEN socket — its event loop held open by the lens WS server / bridge sockets while its HTTP
241
- * listener is gone. Nothing exits, so nothing auto-heals, and the app 502s until a human kills the pid
242
- * (foodguard 2026-07-29 — see {@link LENS_PROBE_ENABLED}).
243
- *
244
- * The caller owns the socket I/O (a cheap TCP connect per tick) and accumulates `probeFails`; this owns
245
- * WHEN that evidence justifies a kill. Three guards, each earning its place:
246
- * - **grace** — a still-booting vite has no listener YET; killing it would be the outage, not the fix.
247
- * - **N consecutive failures** (≥2, enforced here so even a misconfigured `failureThreshold: 1` can't
248
- * single-blip recycle) — a momentary refusal is not a wedge.
249
- * - **cooldown** — the same per-project window the two recycle paths share, so probe + staleness can
250
- * never thrash one project between them.
251
- * Returns the recycle reason or `null`. Kept pure (no process, no socket) so all four are unit-tested.
252
- */
253
- function lensChildWedged(child, lastRecycleAt, now, cfg) {
254
- if (now - child.spawnedAt < cfg.graceMs)
255
- return null; // still booting — it has no listener YET
256
- if (now - lastRecycleAt < cfg.cooldownMs)
257
- return null; // anti-thrash (shared with the staleness recycle)
258
- if (child.probeFails < Math.max(2, cfg.failureThreshold))
259
- return null; // one blip is never a wedge
260
- return 'wedged-no-listener';
261
- }
262
- /**
263
- * PURE decision: was this child's exit a lost BIND RACE (EADDRINUSE) rather than a crash?
264
- *
265
- * The auto-heal respawned on a fixed backoff while the child it replaced still held the pinned port, so
266
- * vite's `--strictPort` exited code=1 on bind — and that self-inflicted exit consumed a heal attempt,
267
- * pushing the backoff up and lengthening the very 502 it was healing (observed: `listen EADDRINUSE
268
- * 127.0.0.1:50059` → `exited (code=1 sig=null); auto-healing in 3s (attempt 2)`). A bind race is an
269
- * ENVIRONMENT condition, not a code fault, so it must not spend the crash-loop budget.
270
- *
271
- * The signature, from evidence we already have (stdio is `inherit`, so the child's stderr is unreadable):
272
- * a non-zero exit, NOT from a signal (we killed it), FAST enough that it cannot have served traffic, while
273
- * some OTHER process still holds the port. If the port is free by now the next spawn will simply succeed,
274
- * so it is treated as a normal heal — the conservative direction.
275
- */
276
- function isPortConflictExit(exit, cfg) {
277
- if (exit.signal)
278
- return false; // we (or the OS) killed it — a recycle/reap, never a bind failure
279
- if (exit.code === null || exit.code === 0)
280
- return false; // a clean exit is not a start failure
281
- if (exit.ranMs > cfg.fastFailMs)
282
- return false; // it RAN ⇒ it held the port ⇒ cannot be a bind race
283
- return exit.portHeld; // someone else still holds it ⇒ this exit lost the bind race
284
- }
285
- function createServiceLayer(rt, obs) {
286
- const { emit } = obs;
287
- // Route a service's lifecycle events (starting/up/down/cold-start/…) to ITS OWNING workspace's bus —
288
- // mirrors the pod runtime-event routing below (svc.wsKey === rt.wsKey ? rt.eventFile : <root>/.lensmcp).
289
- // The daemon's own services stay on rt.eventFile; a REGISTERED workspace's go to its own bus, so each
290
- // hosted dashboard's cluster view (/<key>/api/cluster) is scoped to ITS workspace, not the daemon's
291
- // (foodguard/cluster was empty because every service event went to the daemon's tetros bus). Two
292
- // workspaces can share a service NAME (both have `auth`), so this MUST key off svc.wsKey, not the project.
293
- const serviceBus = (svc) => svc.wsKey === rt.wsKey ? undefined : path.join(svc.root, '.lensmcp', 'events.jsonl');
294
- const emitSvc = (svc, severity, title, fingerprint, raw) => emit(severity, title, fingerprint, { wsKey: svc.wsKey, ...raw }, undefined, serviceBus(svc));
295
- const scanNow = (pool) => { pool.scannedAt = 0; return (0, discovery_1.pickSock)(pool, rt.scanTtlMs) ? pool.socks.length : 0; };
296
- /**
297
- * FOREIGN-GENERATION REAPER (the respawn deadlock, foodguard 2026-07-26): nx records a
298
- * running continuous task in its workspace-data DB, and a NEW `nx run <svc>:serve-hmr`
299
- * invocation WAITS on that record ("Waiting for <task> in another nx process") — even with
300
- * NX_DAEMON=false. When the record's holder is an ORPHANED runner (a replaced daemon's
301
- * tree, a half-killed generation), every respawn parks forever and `ensureUp` hangs each
302
- * request with no log. Before spawning, kill any nx runner for THIS target that this
303
- * gateway does not own — the runner is the detached group leader, so `kill(-pgid)` reaps
304
- * its whole devserver tree with it. One `ps` exec; a clean cold start is a no-op.
305
- */
306
- const reapForeignRunners = (svc, target) => {
307
- let out;
308
- try {
309
- out = (0, node_child_process_1.execFileSync)('ps', ['ax', '-ww', '-o', 'pid=,pgid=,command='], { encoding: 'utf8', timeout: 5_000 });
310
- }
311
- catch {
312
- return 0;
313
- }
314
- const ownPid = svc.proc?.pid;
315
- let reaped = 0;
316
- for (const line of out.split('\n')) {
317
- const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
318
- if (!m)
319
- continue;
320
- const pid = Number(m[1]);
321
- const pgid = Number(m[2]);
322
- const cmd = m[3];
323
- if (!cmd.includes(`run ${target}`))
324
- continue; // only THIS service's runners
325
- // Only nx runners (direct nx.js entry or the yarn/npx shim) — never a stray grep/editor.
326
- if (!/\bnx(\.js)?\b/.test(cmd))
327
- continue;
328
- // A direct-entry runner carries its workspace's nx path — scope to OUR workspace root so a
329
- // same-named project in another workspace is never touched. (The yarn-shim fallback has no
330
- // path in argv; the target match alone bounds it.)
331
- if (cmd.includes('/node_modules/nx/') && !cmd.includes(svc.root))
332
- continue;
333
- if (pid === process.pid || pid === ownPid)
334
- continue;
335
- if (ownPid !== undefined && pgid === ownPid)
336
- continue; // our own detached tree
337
- try {
338
- process.kill(-pgid, 'SIGKILL');
339
- }
340
- catch {
341
- try {
342
- process.kill(pid, 'SIGKILL');
343
- }
344
- catch { /* already gone */ }
345
- }
346
- reaped += 1;
347
- }
348
- if (reaped > 0) {
349
- console.log(`[gateway] ${target}: reaped ${reaped} stale runner tree(s) — a foreign generation held the nx task lock`);
350
- emitSvc(svc, 'warning', `stale-runner reap: ${svc.project} (${reaped} foreign tree(s))`, `cluster-service:${svc.project}`, { kind: 'stale-runner-reap', project: svc.project, reaped });
351
- }
352
- return reaped;
353
- };
354
- const spawnService = (svc) => {
355
- if (rt.stopped())
356
- return; // never spawn a process after the gateway is told to stop
357
- const target = `${svc.project}:${svc.decl.serveTarget ?? 'serve-hmr'}`;
358
- const min = Math.max(1, svc.decl.minPods ?? 1);
359
- reapForeignRunners(svc, target); // clear nx's running-tasks lock BEFORE the spawn can park on it
360
- svc.lockBlockedAt = undefined;
361
- console.log(`[gateway] starting ${target} (pods=${min})…`);
362
- emitSvc(svc, 'info', `service starting: ${svc.project} (${min} pod${min > 1 ? 's' : ''})`, `cluster-service:${svc.project}`, { kind: 'service-starting', project: svc.project, pods: min });
363
- svc.state = 'starting';
364
- svc.spawnAt = Date.now();
365
- // childCount goes as a CLI option, NOT env: nx executes tasks via its
366
- // daemon, whose environment is not the gateway's — env vars get lost.
367
- // Invoke the workspace's nx entry DIRECTLY (node + nx/dist/bin/nx.js) instead of through the
368
- // `yarn nx` shim: the shim is a ~40MB node process that does nothing after the handoff yet
369
- // lives for the service's whole life — ~600MB across a 15-service cluster. Resolved per
370
- // svc.root so each hosted workspace runs its own nx; falls back to `yarn nx` when the entry
371
- // isn't where node_modules linkers put it (pnp/odd layouts).
372
- const nxEntry = path.join(svc.root, 'node_modules', 'nx', 'dist', 'bin', 'nx.js');
373
- const [spawnCmd, spawnArgs] = fs.existsSync(nxEntry)
374
- ? [process.execPath, [nxEntry, 'run', target, `--childCount=${min}`]]
375
- : ['yarn', ['nx', 'run', target, `--childCount=${min}`]];
376
- const proc = (0, node_child_process_1.spawn)(spawnCmd, spawnArgs, {
377
- // The service's OWN workspace root — `rt.root` for the daemon's own workspace, the registered
378
- // workspace's root for a second one (so ONE daemon hosts several workspaces' devservers — model (A)).
379
- cwd: svc.root,
380
- detached: true,
381
- // NX_DAEMON=false keeps the serve task IN this spawned process group.
382
- // With the daemon on, Nx hands the long-running task to the persistent
383
- // daemon (a DIFFERENT group), so the later process-group kill reaps only
384
- // the `yarn nx` shim and orphans the devserver + pod tree — the classic
385
- // "idle-kill leaves live pods" leak. (--childCount as a CLI arg already
386
- // avoids the env-loss the daemon was relied on for.) NX_DAEMON=false also
387
- // means env vars DO reach the pod, so the east-west key + CA propagate.
388
- // LENSMCP_WS_KEY (the SERVICE's owning workspace) namespaces its pod sock dir
389
- // ($TMPDIR/<wsKey>/<service>-devserver) — the namespaced pool the daemon scans for this fragment.
390
- // LENSMCP_EVENT_FILE points a REGISTERED workspace's pods at ITS OWN bus (<root>/.lensmcp/events.jsonl),
391
- // so its dashboard shows its live traces, not the daemon's. The daemon's own services keep rt.eventFile
392
- // exactly (svc.root === rt.root), so single-workspace behavior is unchanged.
393
- env: {
394
- // FOREIGN-POD ENV SCRUB (pod-env.ts): a registered workspace's pod must NOT inherit the daemon
395
- // host's dotenv-loaded env (nx never overrides an already-set var, so foodguard's PG_URL/REDIS_URL
396
- // riding the daemon once pointed tetros's auth+iam at foodguard's stores). The daemon's own
397
- // workspace keeps the full inherit — its pods reload the same files anyway.
398
- ...(0, pod_env_1.podBaseEnv)(process.env, rt.root, svc.root),
399
- NX_DAEMON: 'false',
400
- // NX_ISOLATE_PLUGINS=false: with the daemon off (above), EVERY serve client builds the
401
- // project graph itself — and with plugin isolation on (nx's default) each client spawns
402
- // 8 plugin-worker processes (~50MB each) that stay ALIVE for the life of the service:
403
- // ~400MB of nx machinery per ~50MB service pod, ~4-5GB across a 13-service cluster
404
- // (measured tetros 2026-07-25). Isolation-off loads plugins in-process: the graph is
405
- // identical and zero workers spawn (nx itself runs isolation-off in sandboxes/WASM —
406
- // a supported mode). NOTE this is authoritative for spawned clients: nx's dotenv load
407
- // never overrides an already-set env var, so a workspace .env cannot flip it back on.
408
- NX_ISOLATE_PLUGINS: 'false',
409
- ...(svc.wsKey ? { LENSMCP_WS_KEY: svc.wsKey } : {}),
410
- LENSMCP_EVENT_FILE: svc.wsKey === rt.wsKey ? rt.eventFile : path.join(svc.root, '.lensmcp', 'events.jsonl'),
411
- // Rides the env inherit down the whole nx chain (nx run → run-executor → serve fork) into
412
- // main.devserver, whose gateway watchdog self-exits the pod tree when this pid dies. Without
413
- // it an UNGRACEFUL gateway death (crash/SIGKILL — no process-group kill runs) orphans the
414
- // entire chain under launchd forever (2026-07-28: a day-old tetros pod tree at PPID 1).
415
- LENSMCP_GATEWAY_PID: String(process.pid),
416
- ...eastWestEnv(rt, svc.project),
417
- },
418
- stdio: ['ignore', 'pipe', 'pipe'],
419
- });
420
- svc.lastErrors = [];
421
- svc.buildFailed = false;
422
- const sniff = (d) => {
423
- const text = d.toString();
424
- for (const line of text.split('\n')) {
425
- if (/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(line)) {
426
- // eslint-disable-next-line no-control-regex -- strip ANSI color codes from child output
427
- const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
428
- svc.lastErrors.push(clean);
429
- if (svc.lastErrors.length > 12)
430
- svc.lastErrors.shift();
431
- svc.buildFailed = true;
432
- // THROTTLE: a crash-looping pod re-prints the same error line on every restart. Emit the
433
- // 'service error' event only when the error line CHANGES or after the throttle window —
434
- // otherwise the bus (and the lens log) floods with identical errors many times a second.
435
- const now = Date.now();
436
- if (clean !== svc.lastErrorEmitSig || now - (svc.lastErrorEmitAt ?? 0) >= types_1.SERVICE_ERROR_THROTTLE_MS) {
437
- svc.lastErrorEmitSig = clean;
438
- svc.lastErrorEmitAt = now;
439
- emitSvc(svc, 'error', `service error: ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-error', project: svc.project, line: clean });
440
- }
441
- }
442
- if (/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(line))
443
- svc.buildFailed = false;
444
- // LOCK-BREAK: nx prints exactly this when a FOREIGN runner holds the running-tasks
445
- // record for our target — this spawn will park forever and every request would hang
446
- // through ensureUp with no further log. Cure it once per spawn: kill our parked
447
- // waiter, reap the foreign tree, and respawn clean.
448
- if (!svc.lockBlockedAt && /Waiting for .+ in another nx process/.test(line)) {
449
- svc.lockBlockedAt = Date.now();
450
- console.log(`[gateway] ${target}: spawn blocked by a stale nx runner — breaking the lock + respawning`);
451
- emitSvc(svc, 'warning', `nx task lock: ${svc.project} blocked by a stale runner — auto-breaking`, `cluster-service:${svc.project}`, { kind: 'nx-lock-break', project: svc.project });
452
- setTimeout(() => {
453
- if (rt.stopped() || !svc.lockBlockedAt)
454
- return;
455
- killSpawned(svc, 'parked on a foreign nx task lock — recycling', { announce: false }); // a recycle: `nx-lock-break` above is the report
456
- reapForeignRunners(svc, target);
457
- setTimeout(() => {
458
- if (!rt.stopped() && !svc.proc && svc.state === 'down')
459
- spawnService(svc);
460
- }, 1_500).unref?.();
461
- }, 250).unref?.();
462
- }
463
- }
464
- };
465
- proc.stdout?.on('data', (d) => { if (rt.stopped())
466
- return; sniff(d); process.stdout.write(`[${svc.project}] ${d}`); });
467
- proc.stderr?.on('data', (d) => { if (rt.stopped())
468
- return; sniff(d); process.stderr.write(`[${svc.project}] ${d}`); });
469
- proc.on('exit', (code) => {
470
- svc.proc = undefined;
471
- svc.state = 'down';
472
- if (rt.stopped())
473
- return; // gateway is tearing down — its dying services' exit chatter is noise
474
- console.log(`[gateway] ${target} exited (${code})`);
475
- emitSvc(svc, code === 0 ? 'info' : 'warning', `service down: ${svc.project} (exit ${code})`, `cluster-service:${svc.project}`, { kind: 'service-down', project: svc.project, code });
476
- // SELF-HEAL: respawn an UNEXPECTEDLY-crashed pod so a transient failure recovers with no manual
477
- // restart. A clean exit (code 0) or a sniffed BUILD failure (a code error → a fix, not a transient
478
- // crash) is left down — scale-from-zero (ensureUp) still spawns it on the next request. A crash-loop
479
- // guard (≤ SERVICE_MAX_RESTARTS within the window, exponential backoff) stops a broken service
480
- // hot-looping the CPU.
481
- if (code === 0 || svc.buildFailed)
482
- return;
483
- const now = Date.now();
484
- svc.restarts = (svc.restarts ?? []).filter((t) => now - t < types_1.SERVICE_RESTART_WINDOW_MS);
485
- if (svc.restarts.length >= types_1.SERVICE_MAX_RESTARTS) {
486
- emitSvc(svc, 'error', `service crash-looping: ${svc.project} — ${types_1.SERVICE_MAX_RESTARTS}+ restarts/min, not auto-respawning (the next request retries)`, `cluster-service:${svc.project}`, { kind: 'service-crash-loop', project: svc.project });
487
- return;
488
- }
489
- svc.restarts.push(now);
490
- const backoffMs = Math.min(8000, 500 * 2 ** (svc.restarts.length - 1)); // 0.5 → 1 → 2 → 4 → 8s
491
- setTimeout(() => {
492
- if (rt.stopped() || svc.proc || svc.state !== 'down')
493
- return; // a request already respawned it, or teardown
494
- console.log(`[gateway] self-heal: respawning ${svc.project} (restart ${svc.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`);
495
- emitSvc(svc, 'info', `self-heal: respawning ${svc.project}`, `cluster-service:${svc.project}`, { kind: 'service-respawn', project: svc.project, attempt: svc.restarts.length });
496
- spawnService(svc);
497
- }, backoffMs);
498
- });
499
- svc.proc = proc;
500
- };
501
- /**
502
- * The RECOVERY edge — the counterpart to `service-down`/`service-recycle`/`pod-recycle`.
503
- *
504
- * The gateway used to announce a service going UP from exactly two places, both inside
505
- * `ensureUp`: the adopt fast-path and the cold-start wait loop. But the proxy only calls
506
- * `ensureUp` when `pickSock` came back EMPTY — so a service that self-healed on its own (the
507
- * exit auto-heal respawn, a recycled pod re-binding, an externally-started devserver) was never
508
- * announced. Its `svc.state` stayed `'starting'` and the lens fold behind `process://cluster`
509
- * latched on the last DEGRADATION forever: `agent://current-status` reported `failing` while the
510
- * app served every request (foodguard 2026-07-31 — four services `starting`, `up: []`, 20×200,
511
- * 0×502).
512
- *
513
- * So the transition lives here, callable from every place that observes a live pod, and the
514
- * evidence is the SAME socket scan the wedge detector uses (`live = scanNow(pool) > 0`) — the
515
- * detector that clears `wedgedSince` must also be the one that records `up`, or the two disagree.
516
- * Idempotent, so the per-request call costs one property compare.
517
- */
518
- const markUp = (svc, how) => {
519
- if (svc.state === 'up')
520
- return false;
521
- svc.state = 'up';
522
- const pods = svc.pool.socks.length;
523
- emitSvc(svc, 'info', `service up: ${svc.project} (${pods} pods, ${how})`, `cluster-service:${svc.project}`, { kind: 'service-up', project: svc.project, pods, adopted: how === 'adopted' });
524
- return true;
525
- };
526
- const ensureUp = async (svc) => {
527
- if (scanNow(svc.pool) > 0) {
528
- markUp(svc, 'adopted');
529
- return true;
530
- }
531
- if (rt.stopped())
532
- return false;
533
- if (svc.state !== 'starting' && !svc.proc)
534
- spawnService(svc); // a live spawn supervises itself — don't stack a second one (EADDRINUSE)
535
- const deadline = Date.now() + rt.startupTimeoutMs;
536
- const failFastAt = Date.now() + 12_000; // a build error within 12s → stop waiting, report the cause
537
- while (!rt.stopped() && Date.now() < deadline) {
538
- await sleep(Math.min(700, rt.startupTimeoutMs / 4));
539
- if (svc.buildFailed && Date.now() > failFastAt)
540
- return false;
541
- if (scanNow(svc.pool) > 0) {
542
- svc.state = 'up';
543
- svc.buildFailed = false;
544
- const durationMs = svc.spawnAt ? Date.now() - svc.spawnAt : undefined;
545
- console.log(`[gateway] ${svc.project} up (${svc.pool.socks.length} pods${durationMs ? `, cold start ${durationMs}ms` : ''})`);
546
- emitSvc(svc, 'info', `cold start: ${svc.project} (${svc.pool.socks.length} pods, ${durationMs ?? '?'}ms)`, `cluster-service:${svc.project}`, { kind: 'cold-start', project: svc.project, pods: svc.pool.socks.length, durationMs });
547
- return true;
548
- }
549
- if (svc.state === 'down')
550
- return false; // spawn died
551
- }
552
- return false;
553
- };
554
- const scaleUp = (svc) => {
555
- const max = svc.decl.maxPods ?? svc.decl.minPods ?? 1;
556
- const cur = svc.pool.socks.length;
557
- const admin = svc.decl.adminUrl ?? (svc.decl.port ? `http://localhost:${svc.decl.port}` : undefined);
558
- if (!admin || cur === 0 || cur >= max || svc.inflight <= cur)
559
- return;
560
- if (Date.now() - svc.lastScaleAt < 5000)
561
- return;
562
- svc.lastScaleAt = Date.now();
563
- const mod = admin.startsWith('https')
564
- ? // eslint-disable-next-line @typescript-eslint/no-require-imports
565
- require('node:https')
566
- : http;
567
- const r = mod.request(`${admin}/webpack/scale`, { method: 'POST', rejectUnauthorized: false }, (rs) => rs.resume());
568
- r.on('error', () => undefined);
569
- r.end(JSON.stringify({ pods: cur + 1 }));
570
- console.log(`[gateway] autoscale ${svc.project}: ${cur} → ${cur + 1} pods (inflight ${svc.inflight})`);
571
- emitSvc(svc, 'info', `autoscale: ${svc.project} ${cur} → ${cur + 1} pods`, `cluster-service:${svc.project}`, { kind: 'autoscale', project: svc.project, from: cur, to: cur + 1, inflight: svc.inflight });
572
- };
573
- // Idle sweeper: kill gateway-spawned lazy services after idleKillSec.
574
- //
575
- // `announce: false` for a caller that is TEARING THE TREE DOWN AS PART OF A RECYCLE. This function
576
- // is the shared teardown for two different intents — "park this service, it's idle" and "kill this
577
- // service, it's broken" — and the `idle-kill` event only describes the first. A recycle caller has
578
- // ALREADY announced the real reason (`service-recycle` / `pod-recycle` / `service-crash-loop`), and
579
- // an `idle-kill` on top would OVERWRITE that fault in the lens fold with a benign `idle` — the
580
- // crash-loop and wedge reports would vanish milliseconds after they were raised. It also read wrong
581
- // in the log ("idle kill: auth (wedged: 0 live pods — recycling)").
582
- const killSpawned = (svc, why, opts) => {
583
- if (!svc.proc)
584
- return;
585
- console.log(`[gateway] ${svc.project} ${why} — scaling to zero.`);
586
- if (opts?.announce !== false) {
587
- emitSvc(svc, 'info', `idle kill: ${svc.project} (${why})`, `cluster-service:${svc.project}`, { kind: 'idle-kill', project: svc.project, why });
588
- }
589
- const dying = svc.proc;
590
- // Detach the corpse's handlers BEFORE killing: a dying webpack/nx flushes
591
- // buffered stdout/stderr as it exits, and those `sniff` callbacks would
592
- // keep mutating svc.lastErrors/buildFailed (phantom build failures) — fatal
593
- // if a fresh request re-spawns this same svc while the corpse still flushes.
594
- try {
595
- dying.stdout?.removeAllListeners('data');
596
- dying.stderr?.removeAllListeners('data');
597
- dying.removeAllListeners('exit');
598
- }
599
- catch { /* ignore */ }
600
- try {
601
- process.kill(-dying.pid, 'SIGTERM');
602
- }
603
- catch {
604
- try {
605
- dying.kill('SIGTERM');
606
- }
607
- catch { /* gone */ }
608
- }
609
- svc.proc = undefined;
610
- svc.state = 'down';
611
- // children die with the tree but their sock FILES survive — remove them so the canvas
612
- // and the proxy never see ghost pods. OWNER-AWARE: a sock whose `.owner` pidfile names a
613
- // LIVE process belongs to a NEWER generation binding concurrently — leave it (the
614
- // devserver's bind-steal owns the path now; unlinking it here recreates the vanished-sock
615
- // wedge this sweep exists to prevent).
616
- setTimeout(() => {
617
- try {
618
- for (const f of fs.readdirSync(svc.pool.dir)) {
619
- if (!f.endsWith('.sock'))
620
- continue;
621
- const sockPath = path.join(svc.pool.dir, f);
622
- try {
623
- const owner = Number(fs.readFileSync(`${sockPath}.owner`, 'utf8').trim());
624
- if (Number.isFinite(owner) && owner > 0) {
625
- try {
626
- process.kill(owner, 0); // throws when the owner is dead
627
- continue; // a LIVE foreign owner — a newer generation's pod; keep it
628
- }
629
- catch { /* dead owner → sweep */ }
630
- }
631
- }
632
- catch { /* no owner file (legacy) → sweep */ }
633
- try {
634
- fs.unlinkSync(sockPath);
635
- }
636
- catch { /* gone */ }
637
- try {
638
- fs.unlinkSync(`${sockPath}.owner`);
639
- }
640
- catch { /* gone */ }
641
- }
642
- }
643
- catch { /* dir gone */ }
644
- svc.pool.socks = [];
645
- svc.pool.scannedAt = 0;
646
- }, 1200).unref?.();
647
- };
648
- // SELF-HEAL a WEDGED service: its gateway-spawned parent is ALIVE but 0 pods are live — an HMR hot-swap
649
- // (or the proxy wedge/connection evict) unlinked the child socket, and `ensureUp` won't respawn because
650
- // its `!svc.proc` guard sees the alive parent (the "ghost-socket zombie" — the evict→respawn gap). Kill
651
- // the wedged tree (killSpawned clears svc.proc + unlinks stale socks) then respawn a fresh pod. A
652
- // crash-loop cap (shared with the exit-self-heal) stops a genuinely-broken service hot-looping.
653
- const recycleWedged = (svc) => {
654
- const now = Date.now();
655
- svc.restarts = (svc.restarts ?? []).filter((t) => now - t < types_1.SERVICE_RESTART_WINDOW_MS);
656
- if (svc.restarts.length >= types_1.SERVICE_MAX_RESTARTS) {
657
- emitSvc(svc, 'error', `service crash-looping: ${svc.project} — wedged ${types_1.SERVICE_MAX_RESTARTS}+×/min, not auto-recycling (the next request retries)`, `cluster-service:${svc.project}`, { kind: 'service-crash-loop', project: svc.project });
658
- killSpawned(svc, 'wedged (crash-loop cap) — leaving down for the next request', { announce: false }); // svc.proc cleared → scale-from-zero can retry
659
- return;
660
- }
661
- svc.restarts.push(now);
662
- console.log(`[gateway] self-heal: recycling WEDGED ${svc.project} (live parent, 0 live pods; ${svc.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`);
663
- emitSvc(svc, 'warning', `self-heal: recycling wedged ${svc.project} (0 live pods)`, `cluster-service:${svc.project}`, { kind: 'service-recycle', project: svc.project, attempt: svc.restarts.length });
664
- killSpawned(svc, 'wedged: 0 live pods — recycling', { announce: false }); // kills the tree + unlinks stale socks (+1200ms) + svc.proc=undefined + state='down'
665
- // A wedge is unexpected — bring it straight back (don't wait for a request). Respawn AFTER killSpawned's
666
- // deferred sock cleanup (1200ms) so the fresh child binds a clean socket path.
667
- setTimeout(() => {
668
- if (!rt.stopped() && !svc.proc && svc.state === 'down')
669
- spawnService(svc);
670
- }, 1500).unref?.();
671
- };
672
- // --- zombie-pod recycle: source-set-change tracking + a fresh-pod recycle for a live-but-stale pod ------
673
- const recycleCfg = {
674
- maxAgeMs: types_1.POD_MAX_AGE_MS, graceMs: types_1.POD_STALE_GRACE_MS,
675
- settleMs: types_1.POD_RECYCLE_SETTLE_MS, cooldownMs: types_1.POD_STALE_RECYCLE_COOLDOWN_MS,
676
- };
677
- let prevSig;
678
- const prevScopedSig = new Map(); // bucket → its last signature
679
- let scanInFlight = false;
680
- let lastScanAt = 0;
681
- /** The distinct top-level buckets owned by a gateway-managed project (services + `lens:true` apps) — every
682
- * OTHER bucket is shared/leaf and belongs in every scope. Recomputed per scan so a workspace registered
683
- * after boot is picked up. */
684
- const managedBuckets = () => {
685
- const roots = rt.projectRoots ?? {};
686
- const names = new Set(rt.services.map((s) => s.project));
687
- for (const r of rt.routes)
688
- if (r.lens)
689
- names.add(r.lens.project);
690
- const buckets = new Set();
691
- for (const n of names) {
692
- const pr = roots[n];
693
- if (!pr)
694
- continue;
695
- const seg = path.relative(rt.root, pr).split(path.sep).filter(Boolean)[0];
696
- if (seg && !seg.startsWith('..'))
697
- buckets.add(seg);
698
- }
699
- return [...buckets];
700
- };
701
- /** project → the absolute dirs its child must watch, or `undefined` when it cannot be narrowed (unknown
702
- * project root / no `projectsConfigurations`) → that child keeps the workspace-wide clock. */
703
- const scopeFor = (project, topLevel, managed, extraExclude) => {
704
- const pr = rt.projectRoots?.[project];
705
- if (!pr)
706
- return undefined;
707
- const dirs = sourceScopeDirs(rt.root, pr, managed, topLevel, extraExclude);
708
- return dirs.length > 0 ? dirs : undefined;
709
- };
710
- // Kick a bounded, async source-set scan when due; on a SET change (file added/removed) stamp the clocks so
711
- // children that predate it recycle. Non-blocking — the sweeper never awaits it. Two tiers: the
712
- // workspace-WIDE signature (the fallback + back-compat clock) and one SCOPED signature per managed
713
- // project's bucket, so a `server/**` add no longer recycles a `web/**` vite (see `sourceScopeDirs`). One
714
- // scan per bucket, not per project — sibling projects in a bucket share a scope, so `web/apps/dashboard`
715
- // and `web/apps/authentication` cost a single walk between them.
716
- const maybeScanSourceSet = () => {
717
- if (scanInFlight || Date.now() - lastScanAt < types_1.POD_STALE_SCAN_MS)
718
- return;
719
- scanInFlight = true;
720
- lastScanAt = Date.now();
721
- void (async () => {
722
- // Per-workspace `.lensmcp/config.json` sourceSet.exclude — re-read per scan so an
723
- // edit takes effect without a gateway restart (one tiny fs read per scan tick).
724
- const extraExclude = (0, scope_1.readSourceSetExclude)(rt.root);
725
- const wide = await sourceSetSignature(rt.root, undefined, extraExclude);
726
- if (prevSig !== undefined && wide !== prevSig) {
727
- rt.sourceSetChangedAt = Date.now();
728
- emit('info', 'source file set changed — stale pods recycle when idle', 'cluster-gateway', { kind: 'source-set-changed' });
729
- }
730
- prevSig = wide;
731
- // Scoped tier. Skipped entirely without project roots (nothing to narrow by) — consumers then read
732
- // the wide clock exactly as before.
733
- if (!rt.projectRoots)
734
- return;
735
- let topLevel;
736
- try {
737
- topLevel = (await fs.promises.readdir(rt.root, { withFileTypes: true }))
738
- .filter((e) => e.isDirectory()).map((e) => e.name);
739
- }
740
- catch {
741
- return;
742
- }
743
- const managed = managedBuckets();
744
- const projects = new Set(rt.services.map((s) => s.project));
745
- for (const r of rt.routes)
746
- if (r.lens)
747
- projects.add(r.lens.project);
748
- // bucket → the scope dirs + the projects sharing it (one walk per bucket).
749
- const byBucket = new Map();
750
- for (const project of projects) {
751
- const dirs = scopeFor(project, topLevel, managed, extraExclude);
752
- if (!dirs)
753
- continue;
754
- const key = dirs.join('|');
755
- const entry = byBucket.get(key) ?? { dirs, projects: [] };
756
- entry.projects.push(project);
757
- byBucket.set(key, entry);
758
- }
759
- const now = Date.now();
760
- for (const [key, { dirs, projects: sharing }] of byBucket) {
761
- const sig = await sourceSetSignature(rt.root, dirs, extraExclude);
762
- const prev = prevScopedSig.get(key);
763
- prevScopedSig.set(key, sig);
764
- if (prev === undefined || sig === prev)
765
- continue;
766
- rt.sourceSetChangedFor ??= new Map();
767
- for (const p of sharing)
768
- rt.sourceSetChangedFor.set(p, now);
769
- emit('info', `source file set changed in ${sharing.join(', ')}'s scope`, 'cluster-gateway', { kind: 'source-set-changed', scoped: true, projects: sharing });
770
- }
771
- })()
772
- .catch(() => undefined)
773
- .finally(() => { scanInFlight = false; });
774
- };
775
- // Recycle a live-but-STALE-VIEW pod (alive + reachable, yet its watcher missed post-spawn file adds).
776
- // Kill it → scale-from-zero (or eager respawn) → a fresh pod re-scans the FS. NOT counted against the
777
- // crash-loop cap (staleness ≠ crash); the per-service cooldown ({@link podRecycleReason}) stops thrash.
778
- const recyclePod = (svc, reason) => {
779
- svc.lastStaleRecycleAt = Date.now();
780
- console.log(`[gateway] zombie recycle: ${svc.project} (${reason}) — stale fs view; fresh pod incoming`);
781
- emitSvc(svc, 'warning', `zombie recycle: ${svc.project} (${reason})`, `cluster-service:${svc.project}`, { kind: 'pod-recycle', project: svc.project, reason });
782
- const eager = !!svc.decl.eager;
783
- killSpawned(svc, `${reason} — recycling stale pod`, { announce: false }); // clears proc, state='down', unlinks socks (+1200ms)
784
- if (eager)
785
- setTimeout(() => { if (!rt.stopped() && !svc.proc && svc.state === 'down')
786
- spawnService(svc); }, 1500).unref?.();
787
- };
788
- const startSweeper = () => {
789
- const sweeper = setInterval(() => {
790
- if (rt.stopped())
791
- return;
792
- if (types_1.POD_RECYCLE_ENABLED)
793
- maybeScanSourceSet();
794
- for (const svc of rt.services) {
795
- if (rt.stopped() || !svc.proc) {
796
- svc.wedgedSince = undefined;
797
- continue;
798
- }
799
- const idle = svc.decl.idleKillSec;
800
- // Idle kill (unchanged): scale a lazy service to zero after its idle window.
801
- if (idle && svc.inflight === 0 && Date.now() - svc.lastUsed > idle * 1000) {
802
- killSpawned(svc, `idle ${idle}s`);
803
- svc.wedgedSince = undefined;
804
- continue;
805
- }
806
- // WEDGE detection: a live parent with 0 live pods that is NOT legitimately cold-starting is wedged.
807
- const coldStarting = svc.state === 'starting' && svc.spawnAt !== undefined && Date.now() - svc.spawnAt < rt.startupTimeoutMs;
808
- const live = scanNow(svc.pool) > 0;
809
- if (live || coldStarting) {
810
- svc.wedgedSince = undefined;
811
- // RECOVERY EDGE. `live` is the exact evidence that just cleared `wedgedSince`, so it must
812
- // also record the service as UP — otherwise the gateway silently forgives a wedge while
813
- // the lens fold stays latched on it. This is the path that heals a service which came
814
- // back WITHOUT a request (the exit auto-heal respawn), since nothing else calls `ensureUp`
815
- // once a pod socket exists. Idempotent: after the first tick it is a property compare.
816
- if (live)
817
- markUp(svc, 'observed');
818
- // ZOMBIE recycle: a live pod (live parent + live socket, answers requests) whose fs view went
819
- // stale — neither exit-respawn nor wedge-recycle sees it. Idle-gated in podRecycleReason so a busy
820
- // pod is never interrupted.
821
- if (live && !coldStarting && types_1.POD_RECYCLE_ENABLED) {
822
- // Prefer this project's SCOPED clock (only the subtrees it can resolve); fall back to the
823
- // workspace-wide one when the scan hasn't produced a scoped verdict for it.
824
- const changedAt = rt.sourceSetChangedFor?.get(svc.project) ?? rt.sourceSetChangedAt ?? 0;
825
- const reason = podRecycleReason(svc, Date.now(), changedAt, recycleCfg);
826
- if (reason)
827
- recyclePod(svc, reason);
828
- }
829
- continue;
830
- }
831
- svc.wedgedSince ??= Date.now();
832
- if (Date.now() - svc.wedgedSince > types_1.WEDGE_RECYCLE_GRACE_MS) {
833
- svc.wedgedSince = undefined;
834
- recycleWedged(svc);
835
- }
836
- }
837
- }, rt.sweepMs);
838
- sweeper.unref();
839
- return sweeper;
840
- };
841
- return { scanNow, spawnService, ensureUp, scaleUp, killSpawned, markUp, startSweeper };
842
- }
1
+ "use strict";var N=Object.defineProperty;var j=(t,c)=>N(t,"name",{value:c,configurable:!0});var I=Object.defineProperty,s=j((t,c)=>I(t,"name",{value:c,configurable:!0}),"s");Object.defineProperty(exports,"__esModule",{value:!0}),exports.sourceSetSignature=sourceSetSignature,exports.sourceScopeDirs=sourceScopeDirs,exports.podRecycleReason=podRecycleReason,exports.lensFrontendStale=lensFrontendStale,exports.lensChildWedged=lensChildWedged,exports.isPortConflictExit=isPortConflictExit,exports.createServiceLayer=createServiceLayer;const tslib_1=require("tslib"),node_child_process_1=require("node:child_process"),fs=tslib_1.__importStar(require("node:fs")),http=tslib_1.__importStar(require("node:http")),path=tslib_1.__importStar(require("node:path")),node_util_1=require("node:util"),discovery_1=require("./discovery"),scope_1=require("./scope"),pod_env_1=require("./pod-env"),types_1=require("./types");function eastWestEnv(t,c){const l={},p=t.serviceKeys[c];if(p&&(l.LENSMCP_SERVICE_KEY=p),t.https){const a=require("../../../basic-ssl").caCertPath();fs.existsSync(a)&&(l.NODE_EXTRA_CA_CERTS=a)}return l}j(eastWestEnv,"eastWestEnv"),s(eastWestEnv,"eastWestEnv");const sleep=s(t=>new Promise(c=>setTimeout(c,t)),"sleep"),SRC_EXCLUDE=new Set(["node_modules","dist","tmp","coverage","build","out","target"]),SRC_EXT=new Set([".ts",".tsx",".js",".jsx",".mjs",".cjs",".css",".scss",".sass",".less",".json",".html",".htm",".vue",".svelte",".mdx"]),SCAN_FILE_CAP=1e5;function fnv1a(t){let c=2166136261;for(let l=0;l<t.length;l+=1)c^=t.charCodeAt(l),c=Math.imul(c,16777619);return c>>>0}j(fnv1a,"fnv1a"),s(fnv1a,"fnv1a");const execFileP=(0,node_util_1.promisify)(node_child_process_1.execFile);async function gitIgnoredPaths(t){try{const{stdout:c}=await execFileP("git",["ls-files","--others","--directory","--ignored","--exclude-standard","-z"],{cwd:t,maxBuffer:33554432,timeout:1e4,windowsHide:!0});return new Set(c.split("\0").filter(Boolean))}catch{return new Set}}j(gitIgnoredPaths,"gitIgnoredPaths"),s(gitIgnoredPaths,"gitIgnoredPaths");async function sourceSetSignature(t,c,l){let p=0,a=0;const v=await gitIgnoredPaths(t),_=s(f=>path.relative(t,f).split(path.sep).join("/"),"rel"),S=s(async(f,k=!0)=>{if(p>SCAN_FILE_CAP)return;let M;try{M=await fs.promises.readdir(f,{withFileTypes:!0})}catch{return}for(const y of M){if(p>SCAN_FILE_CAP)return;const m=y.name,A=path.join(f,m);if(y.isDirectory()){if(!k||m.startsWith(".")||SRC_EXCLUDE.has(m)||l?.has(m)||m.endsWith("-devserver")||v.has(`${_(A)}/`))continue;await S(A)}else if(y.isFile()){const R=m.lastIndexOf(".");if(R<=0||!SRC_EXT.has(m.slice(R))||v.has(_(A)))continue;p+=1,a^=fnv1a(A)}}},"walk");if(c&&c.length>0){await S(t,!1);for(const f of c)await S(f)}else await S(t);return`${p}:${(a>>>0).toString(36)}`}j(sourceSetSignature,"sourceSetSignature"),s(sourceSetSignature,"sourceSetSignature");function sourceScopeDirs(t,c,l,p,a){const v=s(f=>{const k=path.relative(t,f).split(path.sep).filter(Boolean);return k.length===0||k[0].startsWith("..")?"":k[0]},"bucketOf")(c);if(!v)return[];const _=new Set(l.filter(Boolean)),S=new Set([v]);for(const f of p)f.startsWith(".")||SRC_EXCLUDE.has(f)||a?.has(f)||f.endsWith("-devserver")||_.has(f)||S.add(f);return[...S].sort().map(f=>path.join(t,f))}j(sourceScopeDirs,"sourceScopeDirs"),s(sourceScopeDirs,"sourceScopeDirs");function podRecycleReason(t,c,l,p){return!t.proc||t.state!=="up"||t.spawnAt==null||t.inflight>0||c-t.lastUsed<p.settleMs||t.lastStaleRecycleAt!=null&&c-t.lastStaleRecycleAt<p.cooldownMs?null:l>0&&l-t.spawnAt>p.graceMs?"stale-source-set":p.maxAgeMs>0&&c-t.spawnAt>p.maxAgeMs?"max-age":null}j(podRecycleReason,"podRecycleReason"),s(podRecycleReason,"podRecycleReason");function lensFrontendStale(t,c,l,p,a){return!(p-c<a.cooldownMs||l<=0||l-t<=a.graceMs||p-l<a.settleMs)}j(lensFrontendStale,"lensFrontendStale"),s(lensFrontendStale,"lensFrontendStale");function lensChildWedged(t,c,l,p){return l-t.spawnedAt<p.graceMs||l-c<p.cooldownMs||t.probeFails<Math.max(2,p.failureThreshold)?null:"wedged-no-listener"}j(lensChildWedged,"lensChildWedged"),s(lensChildWedged,"lensChildWedged");function isPortConflictExit(t,c){return t.signal||t.code===null||t.code===0||t.ranMs>c.fastFailMs?!1:t.portHeld}j(isPortConflictExit,"isPortConflictExit"),s(isPortConflictExit,"isPortConflictExit");function createServiceLayer(t,c){const{emit:l}=c,p=s(e=>e.wsKey===t.wsKey?void 0:path.join(e.root,".lensmcp","events.jsonl"),"serviceBus"),a=s((e,o,r,n,i)=>l(o,r,n,{wsKey:e.wsKey,...i},void 0,p(e)),"emitSvc"),v=s(e=>(e.scannedAt=0,(0,discovery_1.pickSock)(e,t.scanTtlMs)?e.socks.length:0),"scanNow"),_=s((e,o)=>{let r;try{r=(0,node_child_process_1.execFileSync)("ps",["ax","-ww","-o","pid=,pgid=,command="],{encoding:"utf8",timeout:5e3})}catch{return 0}const n=e.proc?.pid;let i=0;for(const d of r.split(`
2
+ `)){const w=d.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);if(!w)continue;const g=Number(w[1]),u=Number(w[2]),h=w[3];if(h.includes(`run ${o}`)&&/\bnx(\.js)?\b/.test(h)&&!(h.includes("/node_modules/nx/")&&!h.includes(e.root))&&!(g===process.pid||g===n)&&!(n!==void 0&&u===n)){try{process.kill(-u,"SIGKILL")}catch{try{process.kill(g,"SIGKILL")}catch{}}i+=1}}return i>0&&(console.log(`[gateway] ${o}: reaped ${i} stale runner tree(s) \u2014 a foreign generation held the nx task lock`),a(e,"warning",`stale-runner reap: ${e.project} (${i} foreign tree(s))`,`cluster-service:${e.project}`,{kind:"stale-runner-reap",project:e.project,reaped:i})),i},"reapForeignRunners"),S=s(e=>{if(t.stopped())return;const o=`${e.project}:${e.decl.serveTarget??"serve-hmr"}`,r=Math.max(1,e.decl.minPods??1);_(e,o),e.lockBlockedAt=void 0,console.log(`[gateway] starting ${o} (pods=${r})\u2026`),a(e,"info",`service starting: ${e.project} (${r} pod${r>1?"s":""})`,`cluster-service:${e.project}`,{kind:"service-starting",project:e.project,pods:r}),e.state="starting",e.spawnAt=Date.now();const n=path.join(e.root,"node_modules","nx","dist","bin","nx.js"),[i,d]=fs.existsSync(n)?[process.execPath,[n,"run",o,`--childCount=${r}`]]:["yarn",["nx","run",o,`--childCount=${r}`]],w=(0,node_child_process_1.spawn)(i,d,{cwd:e.root,detached:!0,env:{...(0,pod_env_1.podBaseEnv)(process.env,t.root,e.root),NX_DAEMON:"false",NX_ISOLATE_PLUGINS:"false",...e.wsKey?{LENSMCP_WS_KEY:e.wsKey}:{},LENSMCP_EVENT_FILE:e.wsKey===t.wsKey?t.eventFile:path.join(e.root,".lensmcp","events.jsonl"),LENSMCP_GATEWAY_PID:String(process.pid),...eastWestEnv(t,e.project)},stdio:["ignore","pipe","pipe"]});e.lastErrors=[],e.buildFailed=!1;const g=s(u=>{const h=u.toString();for(const E of h.split(`
3
+ `)){if(/ERROR in |error TS\d+|Found \d+ error|Unhandled 'error' event|EADDRINUSE/.test(E)){const $=E.replace(/\x1b\[[0-9;]*m/g,"").trim();e.lastErrors.push($),e.lastErrors.length>12&&e.lastErrors.shift(),e.buildFailed=!0;const x=Date.now();($!==e.lastErrorEmitSig||x-(e.lastErrorEmitAt??0)>=types_1.SERVICE_ERROR_THROTTLE_MS)&&(e.lastErrorEmitSig=$,e.lastErrorEmitAt=x,a(e,"error",`service error: ${e.project}`,`cluster-service:${e.project}`,{kind:"service-error",project:e.project,line:$}))}/No typescript errors found|webpack compiled successfully|Child#\d+ ready/.test(E)&&(e.buildFailed=!1),!e.lockBlockedAt&&/Waiting for .+ in another nx process/.test(E)&&(e.lockBlockedAt=Date.now(),console.log(`[gateway] ${o}: spawn blocked by a stale nx runner \u2014 breaking the lock + respawning`),a(e,"warning",`nx task lock: ${e.project} blocked by a stale runner \u2014 auto-breaking`,`cluster-service:${e.project}`,{kind:"nx-lock-break",project:e.project}),setTimeout(()=>{t.stopped()||!e.lockBlockedAt||(y(e,"parked on a foreign nx task lock \u2014 recycling",{announce:!1}),_(e,o),setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.())},250).unref?.())}},"sniff");w.stdout?.on("data",u=>{t.stopped()||(g(u),process.stdout.write(`[${e.project}] ${u}`))}),w.stderr?.on("data",u=>{t.stopped()||(g(u),process.stderr.write(`[${e.project}] ${u}`))}),w.on("exit",u=>{if(e.proc=void 0,e.state="down",t.stopped()||(console.log(`[gateway] ${o} exited (${u})`),a(e,u===0?"info":"warning",`service down: ${e.project} (exit ${u})`,`cluster-service:${e.project}`,{kind:"service-down",project:e.project,code:u}),u===0||e.buildFailed))return;const h=Date.now();if(e.restarts=(e.restarts??[]).filter($=>h-$<types_1.SERVICE_RESTART_WINDOW_MS),e.restarts.length>=types_1.SERVICE_MAX_RESTARTS){a(e,"error",`service crash-looping: ${e.project} \u2014 ${types_1.SERVICE_MAX_RESTARTS}+ restarts/min, not auto-respawning (the next request retries)`,`cluster-service:${e.project}`,{kind:"service-crash-loop",project:e.project});return}e.restarts.push(h);const E=Math.min(8e3,500*2**(e.restarts.length-1));setTimeout(()=>{t.stopped()||e.proc||e.state!=="down"||(console.log(`[gateway] self-heal: respawning ${e.project} (restart ${e.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`),a(e,"info",`self-heal: respawning ${e.project}`,`cluster-service:${e.project}`,{kind:"service-respawn",project:e.project,attempt:e.restarts.length}),S(e))},E)}),e.proc=w},"spawnService"),f=s((e,o)=>{if(e.state==="up")return!1;e.state="up";const r=e.pool.socks.length;return a(e,"info",`service up: ${e.project} (${r} pods, ${o})`,`cluster-service:${e.project}`,{kind:"service-up",project:e.project,pods:r,adopted:o==="adopted"}),!0},"markUp"),k=s(async e=>{if(v(e.pool)>0)return f(e,"adopted"),!0;if(t.stopped())return!1;e.state!=="starting"&&!e.proc&&S(e);const o=Date.now()+t.startupTimeoutMs,r=Date.now()+12e3;for(;!t.stopped()&&Date.now()<o;){if(await sleep(Math.min(700,t.startupTimeoutMs/4)),e.buildFailed&&Date.now()>r)return!1;if(v(e.pool)>0){e.state="up",e.buildFailed=!1;const n=e.spawnAt?Date.now()-e.spawnAt:void 0;return console.log(`[gateway] ${e.project} up (${e.pool.socks.length} pods${n?`, cold start ${n}ms`:""})`),a(e,"info",`cold start: ${e.project} (${e.pool.socks.length} pods, ${n??"?"}ms)`,`cluster-service:${e.project}`,{kind:"cold-start",project:e.project,pods:e.pool.socks.length,durationMs:n}),!0}if(e.state==="down")return!1}return!1},"ensureUp"),M=s(e=>{const o=e.decl.maxPods??e.decl.minPods??1,r=e.pool.socks.length,n=e.decl.adminUrl??(e.decl.port?`http://localhost:${e.decl.port}`:void 0);if(!n||r===0||r>=o||e.inflight<=r||Date.now()-e.lastScaleAt<5e3)return;e.lastScaleAt=Date.now();const i=(n.startsWith("https")?require("node:https"):http).request(`${n}/webpack/scale`,{method:"POST",rejectUnauthorized:!1},d=>d.resume());i.on("error",()=>{}),i.end(JSON.stringify({pods:r+1})),console.log(`[gateway] autoscale ${e.project}: ${r} \u2192 ${r+1} pods (inflight ${e.inflight})`),a(e,"info",`autoscale: ${e.project} ${r} \u2192 ${r+1} pods`,`cluster-service:${e.project}`,{kind:"autoscale",project:e.project,from:r,to:r+1,inflight:e.inflight})},"scaleUp"),y=s((e,o,r)=>{if(!e.proc)return;console.log(`[gateway] ${e.project} ${o} \u2014 scaling to zero.`),r?.announce!==!1&&a(e,"info",`idle kill: ${e.project} (${o})`,`cluster-service:${e.project}`,{kind:"idle-kill",project:e.project,why:o});const n=e.proc;try{n.stdout?.removeAllListeners("data"),n.stderr?.removeAllListeners("data"),n.removeAllListeners("exit")}catch{}try{process.kill(-n.pid,"SIGTERM")}catch{try{n.kill("SIGTERM")}catch{}}e.proc=void 0,e.state="down",setTimeout(()=>{try{for(const i of fs.readdirSync(e.pool.dir)){if(!i.endsWith(".sock"))continue;const d=path.join(e.pool.dir,i);try{const w=Number(fs.readFileSync(`${d}.owner`,"utf8").trim());if(Number.isFinite(w)&&w>0)try{process.kill(w,0);continue}catch{}}catch{}try{fs.unlinkSync(d)}catch{}try{fs.unlinkSync(`${d}.owner`)}catch{}}}catch{}e.pool.socks=[],e.pool.scannedAt=0},1200).unref?.()},"killSpawned"),m=s(e=>{const o=Date.now();if(e.restarts=(e.restarts??[]).filter(r=>o-r<types_1.SERVICE_RESTART_WINDOW_MS),e.restarts.length>=types_1.SERVICE_MAX_RESTARTS){a(e,"error",`service crash-looping: ${e.project} \u2014 wedged ${types_1.SERVICE_MAX_RESTARTS}+\xD7/min, not auto-recycling (the next request retries)`,`cluster-service:${e.project}`,{kind:"service-crash-loop",project:e.project}),y(e,"wedged (crash-loop cap) \u2014 leaving down for the next request",{announce:!1});return}e.restarts.push(o),console.log(`[gateway] self-heal: recycling WEDGED ${e.project} (live parent, 0 live pods; ${e.restarts.length}/${types_1.SERVICE_MAX_RESTARTS})`),a(e,"warning",`self-heal: recycling wedged ${e.project} (0 live pods)`,`cluster-service:${e.project}`,{kind:"service-recycle",project:e.project,attempt:e.restarts.length}),y(e,"wedged: 0 live pods \u2014 recycling",{announce:!1}),setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.()},"recycleWedged"),A={maxAgeMs:types_1.POD_MAX_AGE_MS,graceMs:types_1.POD_STALE_GRACE_MS,settleMs:types_1.POD_RECYCLE_SETTLE_MS,cooldownMs:types_1.POD_STALE_RECYCLE_COOLDOWN_MS};let R;const D=new Map;let C=!1,T=0;const b=s(()=>{const e=t.projectRoots??{},o=new Set(t.services.map(n=>n.project));for(const n of t.routes)n.lens&&o.add(n.lens.project);const r=new Set;for(const n of o){const i=e[n];if(!i)continue;const d=path.relative(t.root,i).split(path.sep).filter(Boolean)[0];d&&!d.startsWith("..")&&r.add(d)}return[...r]},"managedBuckets"),L=s((e,o,r,n)=>{const i=t.projectRoots?.[e];if(!i)return;const d=sourceScopeDirs(t.root,i,r,o,n);return d.length>0?d:void 0},"scopeFor"),P=s(()=>{C||Date.now()-T<types_1.POD_STALE_SCAN_MS||(C=!0,T=Date.now(),(async()=>{const e=(0,scope_1.readSourceSetExclude)(t.root),o=await sourceSetSignature(t.root,void 0,e);if(R!==void 0&&o!==R&&(t.sourceSetChangedAt=Date.now(),l("info","source file set changed \u2014 stale pods recycle when idle","cluster-gateway",{kind:"source-set-changed"})),R=o,!t.projectRoots)return;let r;try{r=(await fs.promises.readdir(t.root,{withFileTypes:!0})).filter(g=>g.isDirectory()).map(g=>g.name)}catch{return}const n=b(),i=new Set(t.services.map(g=>g.project));for(const g of t.routes)g.lens&&i.add(g.lens.project);const d=new Map;for(const g of i){const u=L(g,r,n,e);if(!u)continue;const h=u.join("|"),E=d.get(h)??{dirs:u,projects:[]};E.projects.push(g),d.set(h,E)}const w=Date.now();for(const[g,{dirs:u,projects:h}]of d){const E=await sourceSetSignature(t.root,u,e),$=D.get(g);if(D.set(g,E),!($===void 0||E===$)){t.sourceSetChangedFor??=new Map;for(const x of h)t.sourceSetChangedFor.set(x,w);l("info",`source file set changed in ${h.join(", ")}'s scope`,"cluster-gateway",{kind:"source-set-changed",scoped:!0,projects:h})}}})().catch(()=>{}).finally(()=>{C=!1}))},"maybeScanSourceSet"),F=s((e,o)=>{e.lastStaleRecycleAt=Date.now(),console.log(`[gateway] zombie recycle: ${e.project} (${o}) \u2014 stale fs view; fresh pod incoming`),a(e,"warning",`zombie recycle: ${e.project} (${o})`,`cluster-service:${e.project}`,{kind:"pod-recycle",project:e.project,reason:o});const r=!!e.decl.eager;y(e,`${o} \u2014 recycling stale pod`,{announce:!1}),r&&setTimeout(()=>{!t.stopped()&&!e.proc&&e.state==="down"&&S(e)},1500).unref?.()},"recyclePod");return{scanNow:v,spawnService:S,ensureUp:k,scaleUp:M,killSpawned:y,markUp:f,startSweeper:s(()=>{const e=setInterval(()=>{if(!t.stopped()){types_1.POD_RECYCLE_ENABLED&&P();for(const o of t.services){if(t.stopped()||!o.proc){o.wedgedSince=void 0;continue}const r=o.decl.idleKillSec;if(r&&o.inflight===0&&Date.now()-o.lastUsed>r*1e3){y(o,`idle ${r}s`),o.wedgedSince=void 0;continue}const n=o.state==="starting"&&o.spawnAt!==void 0&&Date.now()-o.spawnAt<t.startupTimeoutMs,i=v(o.pool)>0;if(i||n){if(o.wedgedSince=void 0,i&&f(o,"observed"),i&&!n&&types_1.POD_RECYCLE_ENABLED){const d=t.sourceSetChangedFor?.get(o.project)??t.sourceSetChangedAt??0,w=podRecycleReason(o,Date.now(),d,A);w&&F(o,w)}continue}o.wedgedSince??=Date.now(),Date.now()-o.wedgedSince>types_1.WEDGE_RECYCLE_GRACE_MS&&(o.wedgedSince=void 0,m(o))}}},t.sweepMs);return e.unref(),e},"startSweeper")}}j(createServiceLayer,"createServiceLayer"),s(createServiceLayer,"createServiceLayer");