@codeyam-editor/codeyam-editor 0.1.5 → 0.1.6

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 (76) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +1884 -210
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/empty-project-dir.js +92 -7
  5. package/npm/fleet-add-failure.js +252 -0
  6. package/npm/fleet-asset-version.js +37 -0
  7. package/npm/fleet-auth-health.js +143 -5
  8. package/npm/fleet-build-data.js +60 -5
  9. package/npm/fleet-cleanup.js +114 -9
  10. package/npm/fleet-destroy-safety.js +230 -13
  11. package/npm/fleet-early-roster.js +87 -0
  12. package/npm/fleet-effect-poll.js +110 -0
  13. package/npm/fleet-env.js +86 -0
  14. package/npm/fleet-ghcr-secret.js +129 -0
  15. package/npm/fleet-git-maintenance.js +142 -0
  16. package/npm/fleet-host-op-limiter.js +68 -0
  17. package/npm/fleet-iap-token.js +165 -0
  18. package/npm/fleet-live-status.js +40 -8
  19. package/npm/fleet-memory.js +245 -9
  20. package/npm/fleet-plans.js +98 -4
  21. package/npm/fleet-pool.js +75 -16
  22. package/npm/fleet-prewarm.js +87 -11
  23. package/npm/fleet-project-drift.js +58 -0
  24. package/npm/fleet-public-url.js +234 -0
  25. package/npm/fleet-queue.js +58 -1
  26. package/npm/fleet-reachability-debounce.js +69 -0
  27. package/npm/fleet-reachability.js +80 -0
  28. package/npm/fleet-reset.js +32 -0
  29. package/npm/fleet-running-plan.js +85 -0
  30. package/npm/fleet-session-token-cache.js +65 -0
  31. package/npm/fleet-setup-steps.js +19 -12
  32. package/npm/fleet-status-refs.js +96 -0
  33. package/npm/fleet-strand.js +39 -8
  34. package/npm/fleet-stuck-status.js +13 -7
  35. package/npm/fleet-vm-access.js +170 -0
  36. package/npm/fleet-vm-probe-target.js +282 -0
  37. package/npm/fleet-vm-readiness.js +136 -3
  38. package/npm/google-ingress.js +15 -0
  39. package/npm/ingress-invoke.js +478 -0
  40. package/npm/launch-config.js +182 -6
  41. package/npm/scenario-check.js +282 -10
  42. package/npm/scenario-handlers.js +85 -27
  43. package/npm/scenario-interactivity.js +145 -6
  44. package/npm/scenario-issues.js +13 -1
  45. package/npm/scenario-mocks.js +129 -26
  46. package/npm/scenario-playwright.js +286 -12
  47. package/npm/script-drift.js +72 -1
  48. package/npm/utils.js +37 -3
  49. package/package.json +5 -5
  50. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-DgM0pTAx.js} +1 -1
  51. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  52. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  53. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  54. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  55. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  56. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  57. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
  58. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  59. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  60. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  61. package/ui/dist/assets/{index-QsOiOG9x.js → index-DCHqRrwO.js} +4 -4
  62. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  63. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  64. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  65. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  66. package/ui/dist/index.html +4 -4
  67. package/ui/dist/open-source/codeyam-counter.png +0 -0
  68. package/ui/dist/open-source/el-carot.png +0 -0
  69. package/ui/dist/open-source/tabcommand.png +0 -0
  70. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  71. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  72. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  73. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  74. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  75. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  76. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -194,6 +194,20 @@ function dashboardHostRule(domain) {
194
194
  };
195
195
  }
196
196
 
197
+ /** Extract the VM number from an LB-ingress resource name this module mints
198
+ * (`vm-<N>-<surface>-neg` or `vm-<N>-<surface>-bes`, surface ∈ SURFACES).
199
+ * Returns the number, or null for any name that isn't a per-VM ingress resource
200
+ * (the shared `codeyam-dashboard-*` backend, a foreign resource, junk). The
201
+ * inverse of negName / backendServiceName — the orphan sweep uses it to map the
202
+ * live LB backends back to the VM numbers that own them. The surface alternation
203
+ * is built from SURFACES so it stays in lockstep with the name derivations.
204
+ * Pure. */
205
+ function vmNumberFromResourceName(name) {
206
+ const re = new RegExp(`^vm-(\\d+)-(?:${SURFACES.join('|')})-(?:neg|bes)$`);
207
+ const m = re.exec(String(name == null ? '' : name).trim());
208
+ return m ? Number(m[1]) : null;
209
+ }
210
+
197
211
  module.exports = {
198
212
  SURFACES,
199
213
  EDITOR_PORT,
@@ -201,6 +215,7 @@ module.exports = {
201
215
  normalizeSurface,
202
216
  negName,
203
217
  backendServiceName,
218
+ vmNumberFromResourceName,
204
219
  urlMapHostRules,
205
220
  hostRuleOperation,
206
221
  backendNegAttachOperation,
@@ -26,6 +26,13 @@ const { resolveFleetDomain } = require('./vm-urls');
26
26
  // would, rather than passing an empty DNS_ZONE that shadows the script default.
27
27
  const DEFAULT_DNS_ZONE = 'fleet-zone';
28
28
 
29
+ // The backend-service timeout (seconds) the editor's WebSocket needs to survive a
30
+ // normal Build session. On an External HTTPS LB this value is the MAX lifetime a
31
+ // WebSocket may stay open; 86400 (24h) clears the longest plausible session while
32
+ // GCP still tears the socket down on client disconnect. Mirrors the literal
33
+ // vm-ingress.sh creates BES with. See plan vm--lb-backend-service-websocket-timeout.
34
+ const BES_WEBSOCKET_TIMEOUT_SEC = 86400;
35
+
29
36
  /** Resolve the fleet domain that governs ingress wiring, or null when none is
30
37
  * configured (legacy quick-tunnel mode). `cfg.fleetDomain` (persisted in
31
38
  * cloud.json) wins; the `FLEET_DOMAIN` env value is the fallback. Both paths go
@@ -130,6 +137,37 @@ function vmIngressStatusInvocation({
130
137
  return { command: 'bash', args: [scriptPath, String(n), 'status'], env };
131
138
  }
132
139
 
140
+ /** Build the local invocation for `scripts/gcp/vm-ingress.sh <N> grant-access` —
141
+ * the grant-only pass that (re-)applies one VM's default IAP access grant
142
+ * (persisted collaborators + standing operator + automation SA) WITHOUT touching
143
+ * any NEG/BES/host-rule resource. Returns `{ command, args, env }`. Like
144
+ * `vmIngressStatusInvocation` but the action is fixed to `grant-access` and
145
+ * APP_PORT is omitted — the grant targets backend services by NAME (keyed off the
146
+ * VM number), never the port. An optional `slug` rides through as VM_SLUG for
147
+ * symmetry with the other actions, though the grant itself is slug-independent.
148
+ * Reconcile spawns this for already-`wired` VMs its re-wire loop skips, so a
149
+ * healed VM whose backends already exist still (re-)grants the SA accessor. */
150
+ function vmIngressGrantAccessInvocation({
151
+ scriptPath,
152
+ vmNumber,
153
+ domain,
154
+ project,
155
+ zone,
156
+ slug,
157
+ } = {}) {
158
+ if (!scriptPath) throw new Error('vmIngressGrantAccessInvocation requires scriptPath');
159
+ const n = Number(vmNumber);
160
+ if (!Number.isInteger(n) || n <= 0) {
161
+ throw new Error(`vm-ingress grant-access requires a positive VM number, got: ${vmNumber}`);
162
+ }
163
+ if (!domain) throw new Error('vmIngressGrantAccessInvocation requires a fleet domain');
164
+ const env = { FLEET_DOMAIN: domain };
165
+ if (project) env.GCP_PROJECT = project;
166
+ if (zone) env.ZONE = zone;
167
+ if (slug != null && String(slug).trim() !== '') env.VM_SLUG = String(slug).trim();
168
+ return { command: 'bash', args: [scriptPath, String(n), 'grant-access'], env };
169
+ }
170
+
133
171
  /** Parse the `verdict=<wired|partial|unwired>` line vm-ingress.sh `status`
134
172
  * prints, returning that verdict or null when the output carries none (e.g. the
135
173
  * script errored before emitting it). Pure string parsing so the reconcile
@@ -143,6 +181,21 @@ function parseIngressStatusVerdict(stdout) {
143
181
  return null;
144
182
  }
145
183
 
184
+ /** Parse the `verdict=<torn-down|orphaned-ingress>` line vm-ingress.sh `down`
185
+ * prints, returning that verdict or null when the output carries none (e.g. the
186
+ * script died before emitting it, or was spawned with an older script). Pure
187
+ * string parsing so the dashboard's destroy-teardown handling — clean vs record
188
+ * an orphaned-ingress attention item — stays unit-testable without spawning the
189
+ * script. Mirrors parseIngressStatusVerdict for the `down` verdict vocabulary. */
190
+ function parseIngressTeardownVerdict(stdout) {
191
+ if (typeof stdout !== 'string') return null;
192
+ for (const line of stdout.split('\n')) {
193
+ const m = /^verdict=(torn-down|orphaned-ingress)\s*$/.exec(line.trim());
194
+ if (m) return m[1];
195
+ }
196
+ return null;
197
+ }
198
+
146
199
  /** Pick which rostered VMs reconcile must re-wire from their status verdicts.
147
200
  * `statuses` is `[{ vmNumber, verdict, ... }]`; a VM is selected iff its verdict
148
201
  * is `unwired` or `partial` (it has missing LB resources). `wired` VMs are left
@@ -155,6 +208,194 @@ function selectIngressReconcileTargets(statuses) {
155
208
  return statuses.filter((s) => s && (s.verdict === 'unwired' || s.verdict === 'partial'));
156
209
  }
157
210
 
211
+ /** Pick which rostered VMs reconcile must apply the grant-only IAP pass to — the
212
+ * complement of the re-wire loop that closes the SA-accessor heal-path gap. A VM
213
+ * is selected iff its verdict is `wired`: its backend services already exist (so
214
+ * they are the grant's target) but the re-wire loop SKIPS it, meaning `up` — the
215
+ * only path that (re-)grants the automation SA the IAP accessor — never runs for
216
+ * it. Re-wire targets (`unwired`/`partial`) are excluded here because their `up`
217
+ * already grants; an unknown/null verdict (the probe failed) is excluded too —
218
+ * the BES may not exist, so a grant would only warn. The result is disjoint from
219
+ * `selectIngressReconcileTargets`, so no VM is both re-wired and grant-passed.
220
+ * Returns the selected entries in input order, preserving extra fields (slug).
221
+ * Pure. */
222
+ function selectIngressGrantOnlyTargets(statuses) {
223
+ if (!Array.isArray(statuses)) return [];
224
+ return statuses.filter((s) => s && s.verdict === 'wired');
225
+ }
226
+
227
+ /** Compute the `process.kill` target that reaps a timed-out reconcile's ENTIRE
228
+ * process group. The dashboard spawns `cloud:reconcile-ingress` detached, so the
229
+ * child shell leads its own process group (pgid === child.pid) and a negative-pid
230
+ * signal reaps every descendant — `node cloud.js` → `bash vm-ingress.sh` →
231
+ * `gcloud` — instead of only the immediate `sh -c` child (what an exec/SIGTERM
232
+ * timeout does, orphaning the descendants to init where they pile up and wedge the
233
+ * dashboard). Returns `-pid` for a real detached child, or `null` when the pid is
234
+ * missing / already reaped or would be catastrophic to negate. The `> 1` guard is
235
+ * load-bearing: `process.kill(-1, …)` signals EVERY process the user owns and
236
+ * `process.kill(0, …)`/`-0` targets the caller's OWN group — a missing pid must
237
+ * never be turned into a fleet-wide or self-directed SIGKILL. Pure: pid in, target
238
+ * out, so the timeout-kill decision is unit-testable without spawning a process. */
239
+ function reconcileGroupKillTarget(childPid) {
240
+ if (!Number.isInteger(childPid) || childPid <= 1) return null;
241
+ return -childPid;
242
+ }
243
+
244
+ /** Pick the genuine orphans from the VM numbers that currently HAVE LB ingress
245
+ * resources (`ingressVmNumbers`, derived from the live backend-service AND NEG
246
+ * names). A number is an orphan iff it has ingress but is NOT in the roster,
247
+ * has NO in-flight add, AND is NOT a live GCP instance — the false-orphan
248
+ * guards (vm--auto-clean-orphaned-lb-ingress) that stop the auto-clean from
249
+ * ripping ingress out from under a VM that is mid-provision (its `add-<n>.json`
250
+ * sidecar and, moments later, its roster entry land before ingress finishes
251
+ * wiring), transiently unlisted, or genuinely running but not yet re-rostered
252
+ * (a roster drift — tearing down a *live* VM's ingress would knock it offline).
253
+ * Numbers not yet rostered but with an in-flight add, or with a live instance,
254
+ * are treated as live, not orphans. `liveVmNumbers` is optional; omit it (or
255
+ * pass a non-array) to disable the live-instance guard. Returns the orphan
256
+ * numbers deduped and sorted ascending. Pure. */
257
+ function selectOrphanIngressTargets({ ingressVmNumbers, roster, inFlightAdds, liveVmNumbers } = {}) {
258
+ const rosterSet = new Set((Array.isArray(roster) ? roster : []).map(Number));
259
+ const inFlightSet = new Set((Array.isArray(inFlightAdds) ? inFlightAdds : []).map(Number));
260
+ const liveSet = new Set((Array.isArray(liveVmNumbers) ? liveVmNumbers : []).map(Number));
261
+ const orphans = new Set();
262
+ for (const raw of Array.isArray(ingressVmNumbers) ? ingressVmNumbers : []) {
263
+ const n = Number(raw);
264
+ if (!Number.isInteger(n) || n <= 0) continue;
265
+ if (rosterSet.has(n) || inFlightSet.has(n) || liveSet.has(n)) continue;
266
+ orphans.add(n);
267
+ }
268
+ return [...orphans].sort((a, b) => a - b);
269
+ }
270
+
271
+ /** Render a `vm-ingress.sh down` child's termination honestly for an operator log.
272
+ * A child killed by a SIGNAL has a NULL exit code, so the old
273
+ * `code != null ? code : 0` rendering printed the most misleading string
274
+ * available — `exit=0` — for a teardown the supervising timeout had just
275
+ * SIGTERMed (the destroy-time bug: `verdict=none, exit=0` on a script that never
276
+ * got to finish). Reports the signal when there is one, `spawn-failed` when
277
+ * neither a code nor a signal is available, and the real code otherwise. Pure so
278
+ * the reporting is unit-testable without spawning anything. */
279
+ function describeTeardownExit({ status, signal } = {}) {
280
+ if (signal) return `killed=${signal}`;
281
+ if (status == null) return 'exit=spawn-failed';
282
+ return `exit=${status}`;
283
+ }
284
+
285
+ /** Classify one `vm-ingress.sh <N> down` result into cleaned vs failed. A clean
286
+ * teardown requires BOTH the verified `verdict === 'torn-down'` AND a zero exit
287
+ * — anything else (the `orphaned-ingress` verdict, a null/missing verdict from
288
+ * an older script or a crash, a non-zero exit, or a signal kill) is a failure
289
+ * the caller surfaces with its cause. `signal` is optional: pass it and a
290
+ * timeout-killed child is reported as `killed=SIGTERM` instead of masquerading
291
+ * as a clean `exit=0`. Shared by the orphan sweep (npm/cloud.js
292
+ * sweepOrphanIngress, spawnSync) and the dashboard's destroy-time teardown
293
+ * (server.js tearDownVmIngress, detached spawn) so the two can never drift on
294
+ * what "cleaned" means. Returns `{ cleaned: true }` or
295
+ * `{ cleaned: false, cause }`. Pure so the cleaned-vs-failed decision is
296
+ * unit-testable without spawning the script. */
297
+ function classifyOrphanDownResult({ verdict, status, signal } = {}) {
298
+ if (verdict === 'torn-down' && status === 0 && !signal) return { cleaned: true };
299
+ return { cleaned: false, cause: `down verdict=${verdict || 'none'}, ${describeTeardownExit({ status, signal })}` };
300
+ }
301
+
302
+ /** Parse the machine-readable `CY_ORPHAN_SWEEP <json>` summary line the orphan
303
+ * sweep prints into `{ cleaned:[n], failed:[{n, cause}], skipped:[n] }` — plus
304
+ * `stillPresent:[n]` (VM numbers that still own ≥1 LB resource) ONLY when the
305
+ * summary carries that field. Its ABSENCE (an older reconcile binary that never
306
+ * emitted it) is preserved as an absent key, NOT normalized to `[]`, so the
307
+ * dashboard's reconcile can tell "sweep reported no residual" (`[]`) apart from
308
+ * "sweep can't say" (undefined) and leave stale banners untouched in the latter.
309
+ * Returns null when no `CY_ORPHAN_SWEEP` line is present (no sweep ran, or one
310
+ * died before emitting) so the dashboard can distinguish "no sweep ran" from
311
+ * "sweep ran, found nothing". The LAST such line wins (a reconcile emits one).
312
+ * Malformed JSON is skipped, not thrown. Pure so the dashboard's orphan-state
313
+ * reconciliation stays unit-testable without spawning the reconcile. */
314
+ function parseOrphanSweepSummary(stdout) {
315
+ if (typeof stdout !== 'string') return null;
316
+ let found = null;
317
+ for (const line of stdout.split('\n')) {
318
+ const m = /^CY_ORPHAN_SWEEP\s+(\{.*\})\s*$/.exec(line.trim());
319
+ if (!m) continue;
320
+ let o;
321
+ try { o = JSON.parse(m[1]); } catch { continue; }
322
+ const nums = (arr) => (Array.isArray(arr) ? arr.map(Number).filter((n) => Number.isInteger(n) && n > 0) : []);
323
+ found = {
324
+ cleaned: nums(o.cleaned),
325
+ failed: (Array.isArray(o.failed) ? o.failed : [])
326
+ .filter((f) => f && Number.isInteger(Number(f.n)) && Number(f.n) > 0)
327
+ .map((f) => ({ n: Number(f.n), cause: String(f.cause || 'auto-clean did not verify clean') })),
328
+ skipped: nums(o.skipped),
329
+ };
330
+ if (Array.isArray(o.stillPresent)) found.stillPresent = nums(o.stillPresent);
331
+ }
332
+ return found;
333
+ }
334
+
335
+ /** The reconcile-direction mirror of {@link selectOrphanIngressTargets}: given
336
+ * the VM numbers that currently carry an orphan-attention banner (`orphanKeys`,
337
+ * the keys of the dashboard's orphanedIngress map), the live `roster`, and the
338
+ * sweep's `stillPresent` set (VM numbers that still own ≥1 LB resource), decide
339
+ * which banners are STALE and must be cleared. A banner is stale iff its VM is
340
+ * (a) back in the roster — a destroy→re-add churn re-rostered it, freezing an
341
+ * old `failed` banner the sweep never revisits (the VM-3 case) — or (b) reported
342
+ * as owning no residual LB resource (absent from `stillPresent`), e.g. a VM
343
+ * cleaned outside a recorded sweep. When `stillPresent` is NOT an array (an older
344
+ * reconcile binary that never emitted the field) this returns `[]` so the caller
345
+ * leaves every banner untouched rather than clearing all of them — the absence of
346
+ * the field means "the sweep can't say," not "nothing is present." Junk keys
347
+ * (non-positive / non-integer) are ignored. Returns the VM numbers to clear,
348
+ * deduped ascending. Pure so the reconcile is unit-testable without the server. */
349
+ function staleOrphanEntries({ orphanKeys, roster, stillPresent } = {}) {
350
+ if (!Array.isArray(stillPresent)) return [];
351
+ const present = new Set(stillPresent.map(Number));
352
+ const rosterSet = new Set((Array.isArray(roster) ? roster : []).map(Number));
353
+ const stale = new Set();
354
+ for (const raw of Array.isArray(orphanKeys) ? orphanKeys : []) {
355
+ const n = Number(raw);
356
+ if (!Number.isInteger(n) || n <= 0) continue;
357
+ if (rosterSet.has(n) || !present.has(n)) stale.add(n);
358
+ }
359
+ return [...stale].sort((a, b) => a - b);
360
+ }
361
+
362
+ /** Decide the next step of the add-time ingress CONVERGE loop
363
+ * (vm--add-time-url-wiring-converges-reliably). The detached add-tail no longer
364
+ * fires `vm-ingress.sh up` ONCE and trusts the slow periodic reconcile to catch
365
+ * up — it re-runs `up`+`status` on its own tight cadence until the VM's verdict
366
+ * is `wired` or a bounded deadline elapses. This pure decision keeps that loop
367
+ * unit-testable without spawning the script or sleeping:
368
+ *
369
+ * - `verdict === 'wired'` → `{ done: true, wired: true }`. Converged; stop.
370
+ * - elapsed ≥ `deadlineMs` → `{ done: true, wired: false }`. Give up; the
371
+ * caller surfaces `wiring-failed` (a real attention reason) rather than a
372
+ * silent `partial` left for the sweep.
373
+ * - otherwise → `{ done: false, wired: false, delayMs }` where `delayMs` is an
374
+ * exponential backoff `baseDelayMs * 2**attempt` capped at `maxDelayMs`, so
375
+ * the first retries are quick (seconds) and later ones ease off.
376
+ *
377
+ * `attempt` is 0-based (the delay AFTER the Nth attempt). Pure. */
378
+ function ingressConvergeDecision({
379
+ verdict,
380
+ elapsedMs,
381
+ attempt,
382
+ deadlineMs,
383
+ baseDelayMs,
384
+ maxDelayMs,
385
+ } = {}) {
386
+ if (verdict === 'wired') return { done: true, wired: true, delayMs: 0 };
387
+ const elapsed = Number(elapsedMs) || 0;
388
+ const deadline = Number(deadlineMs);
389
+ if (Number.isFinite(deadline) && elapsed >= deadline) {
390
+ return { done: true, wired: false, delayMs: 0 };
391
+ }
392
+ const base = Number(baseDelayMs) || 0;
393
+ const max = Number.isFinite(Number(maxDelayMs)) ? Number(maxDelayMs) : base;
394
+ const a = Math.max(0, Number(attempt) || 0);
395
+ const delayMs = Math.min(max, base * 2 ** a);
396
+ return { done: false, wired: false, delayMs };
397
+ }
398
+
158
399
  // =============================================================================
159
400
  // Async add-time wiring (vm--async-add-time-lb-ingress-wiring)
160
401
  // =============================================================================
@@ -200,6 +441,23 @@ function ingressInflightMarkerPath(dir, vmNumber) {
200
441
  return `${dir.replace(/\/+$/, '')}/cy-ingress-inflight-vm-${n}`;
201
442
  }
202
443
 
444
+ /** The per-VM marker file the detached add-tail drops when its converge loop
445
+ * exhausts the deadline WITHOUT reaching `wired`
446
+ * (vm--add-time-url-wiring-converges-reliably). It is the durable signal that
447
+ * the URL is genuinely `wiring-failed` — a real attention reason the card shows
448
+ * with the re-run command — as opposed to still-`wiring` (the inflight marker
449
+ * above) or a transient probe miss. The tail clears any prior failed marker when
450
+ * it re-enters the loop and unlinks it on success, so a later self-heal doesn't
451
+ * leave a stale failure behind. Pure path derivation; the create/unlink/stat I/O
452
+ * lives at the call sites, mirroring the inflight-marker split. */
453
+ function ingressFailedMarkerPath(dir, vmNumber) {
454
+ const n = Number(vmNumber);
455
+ if (!Number.isInteger(n) || n <= 0) {
456
+ throw new Error(`ingressFailedMarkerPath requires a positive VM number, got: ${vmNumber}`);
457
+ }
458
+ return `${dir.replace(/\/+$/, '')}/cy-ingress-failed-vm-${n}`;
459
+ }
460
+
203
461
  /** Decide whether a freshly-added VM's detached wiring is still in flight, given
204
462
  * the marker file's CONTENTS (the unix-ms start timestamp the tail wrote) and the
205
463
  * current time. Returns false when the marker is missing/empty (`content` null) —
@@ -251,6 +509,29 @@ function iapAccessInvocation({
251
509
  return { command: 'bash', args: [scriptPath, String(n), action], env };
252
510
  }
253
511
 
512
+ /** Extract the `BLOCKED:` / `Next valid action:` recovery pair out of a failed
513
+ * script's combined stdout+stderr. PURE.
514
+ *
515
+ * The CLI error conventions (CLAUDE.md) require those two markers to be emitted
516
+ * together, and the actionable content is the `Next valid action:` body — an
517
+ * org-policy remedy, a re-run command. Callers previously surfaced
518
+ * `out.slice(-400)`, a blind tail that just as easily lands mid-stack-trace and
519
+ * drops the remedy entirely; that opacity is most of why the 2026-07-30 VM-10
520
+ * lockout took hours to diagnose.
521
+ *
522
+ * Returns the text from the FIRST `BLOCKED:` marker to the end of the output,
523
+ * whitespace-collapsed for a single-line toast, capped at `maxLen`. Returns ''
524
+ * when the output carries no recovery pair, so callers can fall back to their
525
+ * existing tail rather than showing an empty message. */
526
+ function extractRecoveryLines(output, maxLen = 600) {
527
+ const text = String(output == null ? '' : output);
528
+ const start = text.indexOf('BLOCKED:');
529
+ if (start === -1) return '';
530
+ if (!/Next valid action:/.test(text.slice(start))) return '';
531
+ const block = text.slice(start).replace(/\s+/g, ' ').trim();
532
+ return block.length > maxLen ? `${block.slice(0, maxLen - 1)}…` : block;
533
+ }
534
+
254
535
  /** Build the local invocation for `scripts/gcp/google-ingress.sh` — the shared,
255
536
  * VM-independent LB infra (static IP, wildcard cert, Cloud DNS, URL map, IAP
256
537
  * brand). Returns `{ command, args, env }`; `env` carries only the keys the
@@ -275,18 +556,215 @@ function sharedIngressInvocation({
275
556
  return { command: 'bash', args: [scriptPath], env };
276
557
  }
277
558
 
559
+ /** Build the `gcloud compute backend-services update <bes> --global
560
+ * --timeout=<sec>` invocation — the periodic ingress reconcile's per-BES
561
+ * WebSocket-timeout converge. On a GCP External HTTPS LB the backend-service
562
+ * timeout is the MAX WebSocket lifetime (not a request/idle timeout), so a BES
563
+ * left at GCP's 30s default guillotines the editor's /ws/terminal socket every
564
+ * 30s (the Build-tab "Reconnecting…" loop). Returns `{ command, args }`;
565
+ * `project` adds `--project <id>` when supplied (omitted so gcloud's active
566
+ * config applies). `timeoutSec` defaults to {@link BES_WEBSOCKET_TIMEOUT_SEC}
567
+ * (86400 = 24h). Pure argv shaping, unit-tested without invoking gcloud — the
568
+ * same single-source posture as the bash always-converge in vm-ingress.sh. See
569
+ * plan vm--lb-backend-service-websocket-timeout. */
570
+ function backendServiceTimeoutInvocation({ bes, project, timeoutSec = BES_WEBSOCKET_TIMEOUT_SEC } = {}) {
571
+ if (bes == null || String(bes).trim() === '') {
572
+ throw new Error('backendServiceTimeoutInvocation requires a backend-service name');
573
+ }
574
+ const sec = Number(timeoutSec);
575
+ if (!Number.isInteger(sec) || sec <= 0) {
576
+ throw new Error(`backendServiceTimeoutInvocation requires a positive timeoutSec, got: ${timeoutSec}`);
577
+ }
578
+ const args = [
579
+ 'compute', 'backend-services', 'update', String(bes).trim(),
580
+ '--global', `--timeout=${sec}`,
581
+ ];
582
+ if (project != null && String(project).trim() !== '') {
583
+ args.push('--project', String(project).trim());
584
+ }
585
+ return { command: 'gcloud', args };
586
+ }
587
+
588
+ // The shared VPC firewall rule admitting Google's LB + health-check ranges to the
589
+ // fleet. FLEET_FIREWALL_RULE_NAME is the DEPLOYED name — hand-created during the
590
+ // 2026-06-15 LB pilot and adopted as canonical so the converge reaches the live
591
+ // rule with no create/delete against a running fleet. FLEET_FIREWALL_RULE_NAMES
592
+ // additionally carries the drifted name google-ingress.sh managed before the
593
+ // unification (the words reversed), because a converge that describes only one
594
+ // name silently no-ops on a fleet wired under the other. Converging every
595
+ // candidate that exists is safe: `update --rules=tcp` only rewrites allowed
596
+ // ports, never a rule's source ranges or target scope.
597
+ // See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall.
598
+ const FLEET_FIREWALL_RULE_NAME = 'fleet-ingress-allow-lb';
599
+ const FLEET_FIREWALL_RULE_NAMES = [FLEET_FIREWALL_RULE_NAME, 'fleet-ingress-lb-allow'];
600
+
601
+ /** Build the `gcloud compute firewall-rules describe <rule>` invocation that reads
602
+ * the fleet firewall's currently-allowed protocols/ports. The
603
+ * `value(allowed[].map().firewall_rule().list())` projection is the same one
604
+ * google-ingress.sh:350-351 reads, so the bash converge and the reconcile-cadence
605
+ * converge judge the rule from a byte-identical view of it. `project` adds
606
+ * `--project <id>` when supplied (omitted so gcloud's active config applies).
607
+ * Pure argv shaping, unit-tested without invoking gcloud.
608
+ * See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
609
+ function fleetFirewallDescribeInvocation({ ruleName, project } = {}) {
610
+ if (ruleName == null || String(ruleName).trim() === '') {
611
+ throw new Error('fleetFirewallDescribeInvocation requires a firewall rule name');
612
+ }
613
+ const args = [
614
+ 'compute', 'firewall-rules', 'describe', String(ruleName).trim(),
615
+ "--format=value(allowed[].map().firewall_rule().list())",
616
+ ];
617
+ if (project != null && String(project).trim() !== '') {
618
+ args.push('--project', String(project).trim());
619
+ }
620
+ return { command: 'gcloud', args };
621
+ }
622
+
623
+ /** Build the `gcloud compute firewall-rules update <rule> --rules=tcp` invocation
624
+ * that converges the fleet firewall to all-tcp. `update --rules=…` rewrites ONLY
625
+ * the allowed protocols/ports — it leaves the rule's source ranges and target
626
+ * scope untouched — so converging a live rule can never widen or narrow who the
627
+ * rule applies to. That is load-bearing: the deployed rule's scope is the one
628
+ * currently admitting LB traffic to the fleet, and this converge preserves it.
629
+ * `project` adds `--project <id>` when supplied. Pure argv shaping, unit-tested
630
+ * without invoking gcloud.
631
+ * See plan vm--self-healing-app-port-agnostic-fleet-lb-firewall. */
632
+ function fleetFirewallConvergeInvocation({ ruleName, project } = {}) {
633
+ if (ruleName == null || String(ruleName).trim() === '') {
634
+ throw new Error('fleetFirewallConvergeInvocation requires a firewall rule name');
635
+ }
636
+ const args = [
637
+ 'compute', 'firewall-rules', 'update', String(ruleName).trim(),
638
+ '--rules=tcp',
639
+ ];
640
+ if (project != null && String(project).trim() !== '') {
641
+ args.push('--project', String(project).trim());
642
+ }
643
+ return { command: 'gcloud', args };
644
+ }
645
+
646
+ /** Decide whether a described firewall rule still needs the all-tcp converge —
647
+ * the JS twin of the bash guard at google-ingress.sh:352-358. Returns false ONLY
648
+ * for the exact all-tcp value `"tcp"`; every port-pinned list
649
+ * (`"tcp:14199,tcp:5173,tcp:3000"` — the VM-6-on-8080 outage), every
650
+ * protocol-narrowed value, and an empty/absent describe read as "needs converge".
651
+ * Erring toward converging is safe: `update --rules=tcp` is idempotent. */
652
+ function firewallNeedsConverge(currentRulesString) {
653
+ if (currentRulesString == null) return true;
654
+ return String(currentRulesString).trim() !== 'tcp';
655
+ }
656
+
657
+ /** Decide whether a described firewall rule admits TCP traffic to `port` — the
658
+ * tripwire for a masked "HEALTHY but unreachable" preview. The preview BES health
659
+ * check runs on the fixed editor port (14199), so a rule that admits 14199 but not
660
+ * the app's real port reads HEALTHY while every real request is dropped: exactly
661
+ * the VM-6-on-8080 outage. Parses the same `allowed` projection
662
+ * {@link fleetFirewallDescribeInvocation} reads: bare `tcp` admits every port,
663
+ * `tcp:5173,tcp:3000` admits only those, and `tcp:1000-2000` admits an inclusive
664
+ * range. Returns false for an unparseable/empty rule string or a non-numeric port
665
+ * — a gap we cannot disprove is reported, never silently passed. */
666
+ function firewallAdmitsPort(currentRulesString, port) {
667
+ const p = Number(port);
668
+ if (!Number.isInteger(p) || p <= 0) return false;
669
+ if (currentRulesString == null) return false;
670
+ const entries = String(currentRulesString)
671
+ .split(',')
672
+ .map((e) => e.trim())
673
+ .filter(Boolean);
674
+ return entries.some((entry) => {
675
+ const [proto, ports] = entry.split(':');
676
+ if (proto.trim().toLowerCase() !== 'tcp') return false;
677
+ // Bare `tcp` (no port suffix) is the all-tcp converge target — admits everything.
678
+ if (ports == null || ports.trim() === '') return true;
679
+ const [lo, hi] = ports.trim().split('-');
680
+ const low = Number(lo);
681
+ if (!Number.isInteger(low)) return false;
682
+ if (hi == null) return low === p;
683
+ const high = Number(hi);
684
+ if (!Number.isInteger(high)) return false;
685
+ return p >= low && p <= high;
686
+ });
687
+ }
688
+
689
+ /** Build the `gcloud compute backend-services list --global --format=value(name)`
690
+ * invocation the orphan sweep uses to enumerate every LB-ingress backend by
691
+ * name (the sweep maps those names back to VM numbers via
692
+ * google-ingress.vmNumberFromResourceName). `project` adds `--project <id>` when
693
+ * supplied (omitted so gcloud's active config applies). Pure argv shaping,
694
+ * unit-tested without invoking gcloud. */
695
+ function backendServicesListInvocation({ project } = {}) {
696
+ const args = ['compute', 'backend-services', 'list', '--global', '--format=value(name)'];
697
+ if (project != null && String(project).trim() !== '') {
698
+ args.push('--project', String(project).trim());
699
+ }
700
+ return { command: 'gcloud', args };
701
+ }
702
+
703
+ /** Build the `gcloud compute network-endpoint-groups list --format=value(name)`
704
+ * invocation the orphan sweep uses to enumerate every zonal NEG by name. A
705
+ * partial teardown can leave a `vm-<N>-<surface>-neg` behind after its backend
706
+ * service is already gone (the VM-5/6 case), so keying orphan candidates off
707
+ * backend services alone misses that residue — the sweep unions these NEG owners
708
+ * in. `project` adds `--project <id>` when supplied (omitted so gcloud's active
709
+ * config applies). Pure argv shaping, unit-tested without invoking gcloud. */
710
+ function negListInvocation({ project } = {}) {
711
+ const args = ['compute', 'network-endpoint-groups', 'list', '--format=value(name)'];
712
+ if (project != null && String(project).trim() !== '') {
713
+ args.push('--project', String(project).trim());
714
+ }
715
+ return { command: 'gcloud', args };
716
+ }
717
+
718
+ /** Build the `gcloud compute instances list --format=value(name)` invocation the
719
+ * orphan sweep uses for its defensive live-instance guard: a VM whose GCP
720
+ * instance is actually running must never have its ingress torn down, even if a
721
+ * roster drift left it unrostered (a successful teardown would knock a live VM
722
+ * offline). The names map back to VM numbers via fleet-reconcile.parseRunningFleetVms.
723
+ * `project` adds `--project <id>` when supplied. Pure argv shaping, unit-tested
724
+ * without invoking gcloud. */
725
+ function instancesListInvocation({ project } = {}) {
726
+ const args = ['compute', 'instances', 'list', '--format=value(name)'];
727
+ if (project != null && String(project).trim() !== '') {
728
+ args.push('--project', String(project).trim());
729
+ }
730
+ return { command: 'gcloud', args };
731
+ }
732
+
278
733
  module.exports = {
734
+ BES_WEBSOCKET_TIMEOUT_SEC,
735
+ backendServiceTimeoutInvocation,
279
736
  DEFAULT_DNS_ZONE,
280
737
  resolveIngressDomain,
281
738
  resolveDnsZone,
282
739
  regionFromZone,
283
740
  vmIngressInvocation,
284
741
  vmIngressStatusInvocation,
742
+ vmIngressGrantAccessInvocation,
285
743
  parseIngressStatusVerdict,
744
+ parseIngressTeardownVerdict,
286
745
  selectIngressReconcileTargets,
746
+ selectIngressGrantOnlyTargets,
747
+ reconcileGroupKillTarget,
748
+ selectOrphanIngressTargets,
749
+ describeTeardownExit,
750
+ classifyOrphanDownResult,
751
+ parseOrphanSweepSummary,
752
+ staleOrphanEntries,
753
+ backendServicesListInvocation,
754
+ negListInvocation,
755
+ instancesListInvocation,
756
+ FLEET_FIREWALL_RULE_NAME,
757
+ FLEET_FIREWALL_RULE_NAMES,
758
+ fleetFirewallDescribeInvocation,
759
+ fleetFirewallConvergeInvocation,
760
+ firewallNeedsConverge,
761
+ firewallAdmitsPort,
762
+ ingressConvergeDecision,
287
763
  detachedAddTailInvocation,
288
764
  ingressInflightMarkerPath,
765
+ ingressFailedMarkerPath,
289
766
  isWiringInflight,
290
767
  iapAccessInvocation,
768
+ extractRecoveryLines,
291
769
  sharedIngressInvocation,
292
770
  };