@codeyam-editor/codeyam-editor 0.1.15 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,21 @@
1
1
  'use strict';
2
2
 
3
3
  const fc = require('./fleet-cleanup');
4
+ const {
5
+ stockoutCapacityPhrase,
6
+ missingBranchRefFacts,
7
+ missingBranchRefRecovery,
8
+ wedgedInsertFacts,
9
+ wedgedInsertRecovery,
10
+ iapNotReadyFacts,
11
+ iapNotReadyRecovery,
12
+ streamedLibraryMissingFacts,
13
+ cleanTeardownReported,
14
+ streamedLibraryMissingRecovery,
15
+ hostDiskExhausted,
16
+ hostDiskExhaustedRecovery,
17
+ cloudUpFailedRecovery,
18
+ } = require('./fleet-add-failure-evidence');
4
19
  const { parseRosterMarkers } = require('./fleet-early-roster');
5
20
 
6
21
  // npm/fleet-add-failure.js — pure classifier for a FAILED fleet-dashboard `add`
@@ -196,291 +211,6 @@ function nextValidActionRemedy(text) {
196
211
  return i === -1 ? '' : lines.slice(i).join('\n').trim();
197
212
  }
198
213
 
199
- // Name the capacity GCE was short of, from whatever the stockout transcript
200
- // happens to expose — for the capacity-stockout recovery line. GCE reports this
201
- // inconsistently: the 2026-08-20 VM-11 transcript carried `vmType:` / `zone:`
202
- // bullets under errorInfo.metadatas, while the 2026-08-18 VM-13 spelling
203
- // (ZONE_RESOURCE_POOL_EXHAUSTED_WITH_DETAILS) carried neither. So every part is
204
- // optional and the phrase degrades to a generic-but-true sentence rather than
205
- // interpolating `undefined` into operator-facing copy. Extracted alongside
206
- // nextValidActionRemedy (the same shape: a pure transcript reader that enriches
207
- // one arm's recovery text) so the degradation is asserted directly.
208
- //
209
- // `zone:` is anchored with ^\s* so the neighbouring `zonesAvailable:` bullet —
210
- // which lists the zones that DID have capacity — cannot be mistaken for the zone
211
- // that failed. Never throws; returns the generic phrase for empty input.
212
- function stockoutCapacityPhrase(logTail) {
213
- const log = String(logTail || '');
214
- const vmType = (log.match(/^\s*vmType:\s*(\S+)/m) || [])[1];
215
- const zone = (log.match(/^\s*zone:\s*(\S+)/m) || [])[1];
216
- return [vmType, 'capacity', zone ? `in ${zone}` : 'in the target zone']
217
- .filter(Boolean)
218
- .join(' ');
219
- }
220
-
221
- // The spellings git uses for "that branch is not on the remote", each capturing
222
- // the ref name. The union is not defensive padding — the two bootstrap scripts
223
- // reach the remote with DIFFERENT git commands, and git words the same condition
224
- // differently for each:
225
- // `git fetch <url> <branch>` (clone_editor_repo, scripts/gcp-bootstrap.sh)
226
- // → fatal: couldn't find remote ref <ref>
227
- // `git clone --branch <b>` (clone_project, scripts/gcp-project-bootstrap.sh)
228
- // → fatal: Remote branch <b> not found in upstream origin
229
- // Keying on only the first — the one the 2026-08-25 transcripts happened to
230
- // carry — would miss every client-side clone failure. Same union-of-spellings
231
- // lesson capacity-stockout learned from GCE.
232
- //
233
- // Deliberately NOT included: git's `warning: Could not find remote branch …`,
234
- // which is a submodule-update warning that accompanies a SUCCEEDING command.
235
- // Matching it here would let an add that failed for an unrelated reason claim
236
- // this category — and this arm sits above the generic one, so a false positive
237
- // would suppress the true cause rather than merely add noise.
238
- const MISSING_REF_PATTERNS = [
239
- /couldn't find remote ref (\S+)/i,
240
- /Remote branch (\S+) not found in upstream/i,
241
- ];
242
-
243
- // Which branch died, and which config field carried it — read from the transcript.
244
- //
245
- // The field matters because clientBranch and editorBranch are configured in
246
- // different places: an operator who corrects only one re-issues a request that
247
- // fails identically. It is derived from the log prefix of the nearest preceding
248
- // marker line, because the two bootstrap scripts brand their output distinctly
249
- // (`[gcp-bootstrap]` builds the editor image from the editor branch;
250
- // `[project-bootstrap]` clones the client repo at the client branch). When the
251
- // transcript carries neither marker the field is left null and the copy names
252
- // both fields rather than guessing — the same degrade-to-true-but-generic
253
- // discipline stockoutCapacityPhrase uses. Never throws; returns
254
- // { ref: null, field: null } for input with no missing-ref signature.
255
- function missingBranchRefFacts(logTail) {
256
- const lines = String(logTail || '').split('\n');
257
- for (let i = 0; i < lines.length; i += 1) {
258
- let ref = null;
259
- for (const re of MISSING_REF_PATTERNS) {
260
- const m = lines[i].match(re);
261
- if (m) { ref = m[1]; break; }
262
- }
263
- if (!ref) continue;
264
- let field = null;
265
- for (let j = i; j >= 0; j -= 1) {
266
- if (/\[project-bootstrap\]/.test(lines[j])) { field = 'clientBranch'; break; }
267
- if (/\[gcp-bootstrap\]/.test(lines[j])) { field = 'editorBranch'; break; }
268
- }
269
- return { ref, field };
270
- }
271
- return { ref: null, field: null };
272
- }
273
-
274
- // Operator-facing recovery copy for the missing-branch-ref arm.
275
- //
276
- // Extracted from the arm for the same reason stockoutCapacityPhrase was — it is
277
- // the one arm whose copy VARIES with what the transcript happened to expose, so
278
- // pulling it out lets the wording be asserted directly instead of only through
279
- // classifyAddFailure, and keeps the arm itself a two-line decision.
280
- //
281
- // Two properties are load-bearing and are what the tests pin:
282
- // - It states the outcome FIRST and never borrows the generic arm's "a partial
283
- // instance may exist — reconcile or destroy it" language. At this point git
284
- // has aborted before any image build or container work, so that advice sends
285
- // the operator hunting for a box that was never created — the inversion that
286
- // cost the 2026-08-25 recovery its first hour.
287
- // - The remedy is a DIFFERENT action, never "retry". Re-issuing the same
288
- // config against a deleted ref fails identically forever, which is exactly
289
- // why this category is kept out of ADD_FAILURE_RETRYABLE.
290
- //
291
- // `field` is optional: when the transcript carries no bootstrap marker the copy
292
- // names both fields rather than guessing which one carried the dead ref.
293
- // Pure; no I/O.
294
- function missingBranchRefRecovery({ ref, field } = {}) {
295
- const carried = field
296
- ? `Its ${field} still points at it`
297
- : 'Its clientBranch and editorBranch both need checking';
298
- return (
299
- `Branch '${ref}' is not on origin any more — it was almost certainly pruned when it merged. ` +
300
- 'Nothing was provisioned: git aborted before any image build or container work, so there is no instance, ' +
301
- `disk, NEG or backend service to clean up, and nothing to destroy. ${carried}, so retrying as-is will fail ` +
302
- 'identically every time. Re-add on a branch that still exists (the current fleet branch is the usual answer).'
303
- );
304
- }
305
-
306
- // Facts for the wedged-insert arm: a GCE instance insert that was ACCEPTED and
307
- // then never finished. Returns { wedged, code, instance } — `code` is the GCE
308
- // support code when the transcript carried one, so the recovery copy can hand the
309
- // operator the exact string a Support case needs instead of making them dig it out
310
- // of the transcript by hand.
311
- //
312
- // Two spellings reach here and both are proof, so the match is a UNION:
313
- // A. The raw GCE prose, which is what the 2026-09-04 VM-8 transcript carried:
314
- // `Internal error. Please try again or contact Google Support. (Code: '…')`
315
- // PAIRED with evidence this run actually attempted a create. The pairing is
316
- // what keeps the arm narrow — the same prose surfacing from some other
317
- // gcloud call is not an insert that wedged, and must not claim to be one.
318
- // B. ensureInstance's own fast-fail line (npm/cloud.js), which fires when a
319
- // re-add finds the slot still parked in PROVISIONING behind an operation
320
- // that never reached DONE. That path exists precisely so the transcript
321
- // carries a signature instead of an uninformative 2-minute timeout, so this
322
- // arm keys on it directly.
323
- //
324
- // Deliberately NOT keyed on elapsed time. A duration heuristic ("PROVISIONING for
325
- // too long") would misfire on a legitimately slow create; an INTERNAL_ERROR on an
326
- // accepted insert is provable evidence, which is the same signature-over-duration
327
- // discipline the capacity-stockout arm uses.
328
- const WEDGED_INSERT_PROSE = /Internal error\. Please try again or contact Google Support/i;
329
- const WEDGED_INSERT_FASTFAIL = /stuck in PROVISIONING behind a wedged create operation/i;
330
- const WEDGED_INSERT_CREATE_ATTEMPTED = /not found\s*—\s*creating|Failed to create instance|did not reach RUNNING/i;
331
-
332
- function wedgedInsertFacts(logTail) {
333
- const log = String(logTail || '');
334
- const wedged = WEDGED_INSERT_FASTFAIL.test(log)
335
- || (WEDGED_INSERT_PROSE.test(log) && WEDGED_INSERT_CREATE_ATTEMPTED.test(log));
336
- if (!wedged) return { wedged: false, code: null, instance: null };
337
- return {
338
- wedged: true,
339
- code: (log.match(/\(Code:\s*'(-?\d+)'\)/) || [])[1] || null,
340
- instance: (log.match(/Checking instance\s+(\S+?)\.\.\./) || [])[1] || null,
341
- };
342
- }
343
-
344
- // Operator-facing recovery copy for the wedged-insert arm, extracted for the same
345
- // reason stockoutCapacityPhrase and missingBranchRefRecovery were: its wording
346
- // varies with what the transcript exposed, so pulling it out lets the copy be
347
- // asserted directly rather than only through classifyAddFailure.
348
- //
349
- // Three properties are load-bearing and are what the tests pin:
350
- // - It never borrows the generic arm's "reconcile or destroy it" line. A delete
351
- // against a wedged slot is ACCEPTED and then queues behind the wedged insert
352
- // rather than clearing it, so that advice sends the operator to an action that
353
- // provably does not work — the same inversion that cost the 2026-08-25
354
- // missing-branch-ref recovery its first hour.
355
- // - It says plainly that this is not the operator's fault and not their fix.
356
- // The only thing that resolves it is GCE-side.
357
- // - It names the Support code when one was captured, because that string is
358
- // what a Google Cloud Support case is keyed on.
359
- function wedgedInsertRecovery({ code, instance } = {}) {
360
- const named = instance || 'the instance';
361
- const cite = code ? ` citing operation error code ${code}` : '';
362
- return (
363
- `GCE accepted the create for ${named} and then never finished it: the insert stayed at status RUNNING, progress 0 ` +
364
- 'and eventually attached an INTERNAL_ERROR without ever reaching DONE, leaving the instance in PROVISIONING and its ' +
365
- 'boot disk in CREATING. Nothing on our side failed and there is nothing here to reconcile. Do NOT retry — the ' +
366
- 'instance name is held by the wedged operation, so a re-add cannot succeed while it is outstanding. Do NOT try to ' +
367
- 'destroy it either — a delete is accepted but queues behind the wedged insert instead of clearing it. This needs ' +
368
- `GCE-side resolution: open a Google Cloud Support case${cite}. If you need the slot before Google releases it, add ` +
369
- 'on a different VM number.'
370
- );
371
- }
372
-
373
- // Facts for the iap-not-ready arm: waitForIapReady (npm/cloud.js) threw its
374
- // readiness timeout. Returns { timedOut, instance, budgetSecs, cause } — `cause` is
375
- // the label waitForIapReady derived from its probe stderr (null when none of its
376
- // signatures matched, or on a transcript from before the label existed), so the
377
- // recovery copy can name WHICH wait was blown instead of only `return code [255]`.
378
- //
379
- // The signature is emitted from exactly one place, and that place runs before any
380
- // bootstrap work, which is what lets this arm assert the instance is bare.
381
- const IAP_NOT_READY = /IAP did not become ready for (\S+) within (\d+)s/;
382
-
383
- function iapNotReadyFacts(logTail) {
384
- const m = String(logTail || '').match(IAP_NOT_READY);
385
- if (!m) return { timedOut: false, instance: null, budgetSecs: null, cause: null };
386
- const cause = (String(logTail).match(/likely cause: ([^;)]+)/) || [])[1] || null;
387
- return { timedOut: true, instance: m[1], budgetSecs: Number(m[2]), cause: cause && cause.trim() };
388
- }
389
-
390
- // Operator-facing recovery copy for the iap-not-ready arm. It states what the
391
- // generic arm cannot: the instance EXISTS and is BARE. "A partial instance may
392
- // exist" is what steered VM-4's operator toward destroy + re-add (15-25 minutes)
393
- // when Retry resumes on the same box in seconds — and it left nothing on the card
394
- // saying a RUNNING, billing instance was sitting there.
395
- function iapNotReadyRecovery({ instance, budgetSecs, cause } = {}) {
396
- const named = instance || 'The instance';
397
- const within = budgetSecs ? ` within ${budgetSecs}s` : '';
398
- const why = cause ? ` (likely cause: ${cause})` : '';
399
- return (
400
- `${named} was created and is RUNNING, but SSH over IAP did not answer${within}${why}. This is usually a transient ` +
401
- 'control-plane race. Nothing was installed on it — the wait runs before any bootstrap step — so it is a bare box and ' +
402
- 'there is nothing partial to reconcile. Retry add resumes on this same instance rather than creating a second one. ' +
403
- 'Until you retry or destroy it, the instance keeps running and billing.'
404
- );
405
- }
406
-
407
- // Facts for the streamed-library-missing arm: a streamed bootstrap script sourced
408
- // a `$HOME`-rooted `lib/…` file that was never shipped to the VM, and bash
409
- // reported `<script>: line N: <path>: No such file or directory`. This took ALL
410
- // fleet provisioning down twice (lib/npm-pin.sh 2026-09-04, lib/render-cloud-
411
- // compose.sh 2026-09-18). Returns { missing, script, library } — `script` is the
412
- // basename of the aborting script, `library` the `lib/…` path relative to $HOME,
413
- // which is how PROJECT_BOOTSTRAP_LIBS (npm/cloud.js) names its entries.
414
- const STREAMED_LIBRARY_MISSING = /(\S+): line \d+: \/(?:home\/[^/\s]+|root)\/(lib\/[^\s:]+): No such file or directory/;
415
-
416
- function streamedLibraryMissingFacts(logTail) {
417
- const m = String(logTail || '').match(STREAMED_LIBRARY_MISSING);
418
- if (!m) return { missing: false, script: null, library: null };
419
- return { missing: true, script: m[1].split('/').pop(), library: m[2] };
420
- }
421
-
422
- // Did cloud:up report that it deleted the instance it created this run? The abort
423
- // path (npm/cloud.js) prints the teardown marker BEFORE it issues the delete, and
424
- // prints a `during abort teardown` warning when that delete fails — so the marker
425
- // alone is a claim of intent, and only the marker WITHOUT the warning is evidence
426
- // that no orphan was left behind.
427
- function cleanTeardownReported(logTail) {
428
- const log = String(logTail || '');
429
- return /tearing down the VM cloud:up created this run \(no orphan left behind\)/.test(log)
430
- && !/during abort teardown/.test(log);
431
- }
432
-
433
- // Operator-facing recovery copy for the streamed-library-missing arm. It names
434
- // what to fix — register the library so it is streamed — and, when the transcript
435
- // proves a clean teardown, says there is no orphan to hunt for. Every add on this
436
- // branch fails identically until the registry is fixed, so it never offers Retry.
437
- function streamedLibraryMissingRecovery({ script, library, cleanTeardown } = {}) {
438
- const lib = library || 'a lib/ file';
439
- const from = script ? ` sourced by ${script}` : '';
440
- const orphan = cleanTeardown
441
- ? 'cloud:up already deleted the VM it created, so there is nothing to reconcile or destroy.'
442
- : 'The transcript does not confirm the VM was deleted — check whether a partial instance exists before re-adding.';
443
- return (
444
- `${lib}${from} was never shipped to the VM, so the bootstrap aborted. Add it to PROJECT_BOOTSTRAP_LIBS in npm/cloud.js, ` +
445
- `push, and re-add — every add from this branch fails the same way until then. ${orphan}`
446
- );
447
- }
448
-
449
- // Did the OPERATOR HOST run out of filesystem? Node spells it `ENOSPC: no space
450
- // left on device`, the shell `No space left on device`, gcloud `OSError: [Errno
451
- // 28] No space left on device` — matched on the message, never on the errno
452
- // number, which means something else on other platforms. A transcript that says a
453
- // script "failed on the VM" is quoting the VM's own log tail (npm/cloud.js prints
454
- // that header above it), so an ENOSPC there is the VM's disk, not the host's, and
455
- // stays with the generic arm.
456
- const HOST_DISK_EXHAUSTED = /ENOSPC: no space left on device|No space left on device/i;
457
-
458
- function hostDiskExhausted(logTail) {
459
- const log = String(logTail || '');
460
- return HOST_DISK_EXHAUSTED.test(log) && !/failed on the VM/i.test(log);
461
- }
462
-
463
- // Operator-facing recovery copy for the host-disk-exhausted arm. The ENOSPC fires
464
- // on the operator host before `instances create`, so nothing exists on GCP; the
465
- // fix is freeing the host's filesystem, which every add needs first.
466
- function hostDiskExhaustedRecovery() {
467
- return (
468
- 'The operator host ran out of filesystem space (bytes or inodes) before anything was created, so there is no VM to ' +
469
- 'reconcile or destroy. Prune old date directories under /root/.config/gcloud/logs — gcloud writes one log per ' +
470
- 'invocation and is the usual cause — and confirm with `df -i` and `df -h`. Every add fails the same way until then.'
471
- );
472
- }
473
-
474
- // Operator-facing recovery copy for the generic cloud-up-failed arm. "A partial
475
- // instance may exist" is only true when the transcript does not show cloud:up
476
- // deleting what it created; saying it regardless sends the operator hunting for
477
- // an orphan that is not there.
478
- function cloudUpFailedRecovery({ cleanTeardown } = {}) {
479
- return cleanTeardown
480
- ? 'cloud:up failed during provisioning and deleted the VM it created, so nothing was left behind. Read the transcript for the cause before re-adding.'
481
- : 'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.';
482
- }
483
-
484
214
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
485
215
  // can never drift from the category.
486
216
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -489,6 +219,40 @@ function buildFailureVerdict(category, headline, recovery, extra) {
489
219
  return out;
490
220
  }
491
221
 
222
+ // Arm 1's verdict: name the drifted files and the exact command that clears them.
223
+ function preflightDriftVerdict(log) {
224
+ const files = extractDriftFiles(log);
225
+ const fileList = files.length ? files.join(', ') : 'the streamed scripts';
226
+ const fix = files.length
227
+ ? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
228
+ : 'Commit & push them, or discard the local edits, then Retry add.';
229
+ return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
230
+ `Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
231
+ { files });
232
+ }
233
+
234
+ // Arm 13's verdict, for a read-back that found no human accessor on the policy.
235
+ function iapReadbackVerdict(log, wiringInFlight) {
236
+ const remedy = nextValidActionRemedy(log);
237
+ // 9a. access-pending — no accessors YET. The grant is the LAST step of the
238
+ // detached wire-add-tail, so while that tail is alive an empty policy is
239
+ // the expected mid-flight reading, not a verdict. Calm: this is the state
240
+ // that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
241
+ // before their grant step had even run (2026-08-11).
242
+ if (wiringInFlight) {
243
+ return buildFailureVerdict('access-pending', 'Access not granted yet',
244
+ 'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
245
+ }
246
+ // 9b. iap-unauthorized — the wiring is done and the policy STILL authorizes
247
+ // nobody, so the public URL returns "Error code 51" for every human. This
248
+ // is the 2026-08-09 case the read-back exists to catch (one step granted
249
+ // the operator, the next revoked him) and it is a genuine hard failure.
250
+ // Not retryable: the VM exists and is fine — re-adding it would duplicate
251
+ // a live box when what is missing is one grant.
252
+ return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
253
+ `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
254
+ }
255
+
492
256
  // Classify a failed add from its transcript tail + exit code.
493
257
  //
494
258
  // logTail the last lines of the provision transcript (string; '' when the
@@ -542,14 +306,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
542
306
  // only on a genuine drift abort.
543
307
  if (/Streamed scripts carry unreviewed local content/i.test(log)
544
308
  || /UNCOMMITTED LOCAL EDITS/.test(log)) {
545
- const files = extractDriftFiles(log);
546
- const fileList = files.length ? files.join(', ') : 'the streamed scripts';
547
- const fix = files.length
548
- ? `Commit & push them, or discard with \`git checkout -- ${files.join(' ')}\`, then Retry add.`
549
- : 'Commit & push them, or discard the local edits, then Retry add.';
550
- return buildFailureVerdict('preflight-drift', 'Blocked by local edits',
551
- `Uncommitted local edits in ${fileList} tripped the streamed-script drift guard, which aborts before any cloud resource is created. ${fix}`,
552
- { files });
309
+ return preflightDriftVerdict(log);
553
310
  }
554
311
 
555
312
  // 2. instance-vanished — the GCP instance is confirmed gone; nothing to orphan.
@@ -789,24 +546,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
789
546
  // always wins if both texts are ever present, and BEFORE converging/verifying
790
547
  // because it is strictly more specific than either.
791
548
  if (/IAP policy authorizes no human/i.test(log)) {
792
- const remedy = nextValidActionRemedy(log);
793
- // 9a. access-pending — no accessors YET. The grant is the LAST step of the
794
- // detached wire-add-tail, so while that tail is alive an empty policy is
795
- // the expected mid-flight reading, not a verdict. Calm: this is the state
796
- // that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
797
- // before their grant step had even run (2026-08-11).
798
- if (wiringInFlight) {
799
- return buildFailureVerdict('access-pending', 'Access not granted yet',
800
- 'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
801
- }
802
- // 9b. iap-unauthorized — the wiring is done and the policy STILL authorizes
803
- // nobody, so the public URL returns "Error code 51" for every human. This
804
- // is the 2026-08-09 case the read-back exists to catch (one step granted
805
- // the operator, the next revoked him) and it is a genuine hard failure.
806
- // Not retryable: the VM exists and is fine — re-adding it would duplicate
807
- // a live box when what is missing is one grant.
808
- return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
809
- `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
549
+ return iapReadbackVerdict(log, wiringInFlight);
810
550
  }
811
551
 
812
552
  // 14. converging — the add reached the early-roster marker (`CY_ROSTER`): the
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const fr = require('./fleet-reachability');
4
+ const fph = require('./fleet-preview-health');
4
5
 
5
6
  // Assemble the per-VM wire object the dashboard's /data response carries to the
6
7
  // browser. This is the single source of truth for a card's transport shape: the
@@ -14,15 +15,14 @@ const fr = require('./fleet-reachability');
14
15
  // it lived in server memory and never reached the browser, and the badge never
15
16
  // appeared. A hand-maintained object literal inside a 197KB monolith has no
16
17
  // guard against that drift; a pure function does.
17
- //
18
- // `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
19
- // cross-VM, and side-effecting values buildData computes per VM (status,
20
- // debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
21
- // the things this pure function can't know on its own.
22
- function buildVmEnvelope(v, ctx) {
18
+
19
+ // The Live Preview half of the envelope: where the preview lives, and the three
20
+ // independent axes the card reads before it offers the 👁 link — is the dev
21
+ // server up, is the public URL wired, does the LB's own dial reach the app —
22
+ // plus the amber badge those readings can raise. Every reading is three-state;
23
+ // none may conflate "down" with "unknown".
24
+ function previewEnvelopeFields(v, n) {
23
25
  return {
24
- n: ctx.n,
25
- url: v.url || null,
26
26
  // The VM's public Live Preview URL (<slug>.preview.<domain> in named-tunnel
27
27
  // mode, else the local forward). The card reads it as `previewSub` to render
28
28
  // the 👁 preview link. Server-computed in server.js and merged onto the poll
@@ -39,14 +39,46 @@ function buildVmEnvelope(v, ctx) {
39
39
  // true, placeholder → false, else null). Reaches every VM over the IAP-LB
40
40
  // seam on the GCE operator host, where the old loopback `previewReachable`
41
41
  // read false fleet-wide. The card gates the clickable 👁 link on `=== true`.
42
- // Three-state, never conflating "down" with "unknown".
43
42
  previewServing: v.previewServing === true ? true : (v.previewServing === false ? false : null),
44
43
  // Sticky preview-INGRESS signal — "is the VM's public preview URL wired?" —
45
44
  // from a reachability probe of <slug>.preview.<domain> over the LB, DISTINCT
46
45
  // from previewServing. true = wired, false = confirmed dead (→ muted 'public
47
46
  // URL not wired' hint), null = unknown (skipped / IAP audience mismatch — a
48
- // null must never gate the link off). Three-state, same discipline.
47
+ // null must never gate the link off).
49
48
  previewIngress: v.previewIngress === true ? true : (v.previewIngress === false ? false : null),
49
+ // The silently-broken Live Preview guard: { previewOrigin, expectedOrigin }
50
+ // when the VM's own /api/config proves its preview can never load in a
51
+ // remote browser (a not-same-origin-safe stack stuck on a loopback/subpath
52
+ // origin), null when healthy or unknown. Drives the card's amber "Live
53
+ // Preview unreachable" badge. Gated on `reachable` like providerAuth — an
54
+ // unreachable VM's last-known config is stale, and an unreachable card has
55
+ // louder problems to report than its preview origin.
56
+ // Two more reasons share the badge (npm/fleet-preview-health.js), each
57
+ // tagged `kind`: 'app-port-drift' when the host publishes one port and the
58
+ // project declares another, and 'endpoint-dead' when a dial of the preview
59
+ // NEG endpoint the LB uses is reset or times out. Either is a preview the
60
+ // editor's own view reports healthy while every remote browser gets Envoy's
61
+ // connection-termination error (VM-15, 2026-10-17).
62
+ previewMisconfigured: fph.resolvePreviewMisconfigured(v, n),
63
+ // The LB-path dial verdict for the preview, { verdict, endpoints }:
64
+ // 'answered' (any HTTP status), 'reset', 'timeout', or 'unknown'. A third
65
+ // preview axis beside previewServing (is the dev server up?) and
66
+ // previewIngress (is the public URL wired?) — a healthy dev server behind a
67
+ // broken port must not read as a dead dev server. null before the first
68
+ // slow gather lands, or off the GCE operator host (no VPC path to dial).
69
+ previewEndpoint: v.previewEndpoint || null,
70
+ };
71
+ }
72
+
73
+ // Build one VM's envelope. `v` is the merged poll state (state.vms[n]). `ctx`
74
+ // carries the derived, cross-VM, and side-effecting values buildData computes
75
+ // per VM (status, debounced broker tri-state, reconciled job, queue position,
76
+ // launch cfg, …) — the things this pure function can't know on its own.
77
+ function buildVmEnvelope(v, ctx) {
78
+ return {
79
+ n: ctx.n,
80
+ url: v.url || null,
81
+ ...previewEnvelopeFields(v, ctx.n),
50
82
  // `reachable` (three-state) + `reachableSource` (which signal decided, or
51
83
  // 'unpolled'). Same tri-state discipline as previewServing/previewIngress
52
84
  // above, but delegated to npm/fleet-reachability.js because FIVE consumers
@@ -137,14 +169,6 @@ function buildVmEnvelope(v, ctx) {
137
169
  // Auth state + reason + token expiry. Only surfaced when the editor is
138
170
  // reachable (an unreachable VM's cached auth is stale and misleading).
139
171
  providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
140
- // The silently-broken Live Preview guard: { previewOrigin, expectedOrigin }
141
- // when the VM's own /api/config proves its preview can never load in a
142
- // remote browser (a not-same-origin-safe stack stuck on a loopback/subpath
143
- // origin), null when healthy or unknown. Drives the card's amber "Live
144
- // Preview unreachable" badge. Gated on `reachable` like providerAuth — an
145
- // unreachable VM's last-known config is stale, and an unreachable card has
146
- // louder problems to report than its preview origin.
147
- previewMisconfigured: (v.reachable && v.previewMisconfigured) || null,
148
172
  // The project's DECLARED datastore and whether it is answering:
149
173
  // { type, target, local, reachable, lastError }, or null when the project
150
174
  // declares none (a Rust CLI, a static site) — which is the common case and
@@ -182,7 +206,10 @@ function buildVmEnvelope(v, ctx) {
182
206
  // The raw LB backend-health map { editor, preview } the readiness derivation
183
207
  // consumed, surfaced so the card can show a precise tooltip (which surface
184
208
  // is unwired). null in legacy mode or before the first slow probe lands.
185
- backendHealth: v.backendHealth || null,
209
+ // The LB health check dials the editor port, so its `preview` verdict cannot
210
+ // speak to the app; `healthy` is kept only when the preview endpoint dial
211
+ // answered, else it reads 'unhealthy' (dial reset/timed out) or 'unverified'.
212
+ backendHealth: fph.gatePreviewBackendHealth(v.backendHealth, v.previewEndpoint),
186
213
  // The per-VM IAP access whitelist (the principals explicitly granted
187
214
  // resource-level access to THIS VM, from the fleet-vm-access sidecar). Merged
188
215
  // onto the poll state at server.js, it drives the card's 🔐 access line and
@@ -215,7 +242,7 @@ const REQUIRED_CARD_FIELDS = [
215
242
  'degradedReasons', 'setupSteps', 'attentionReasons',
216
243
  'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
217
244
  'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
218
- 'brokerDeath', 'providerAuth', 'previewMisconfigured', 'datastore', 'effects', 'currentProvider',
245
+ 'brokerDeath', 'providerAuth', 'previewMisconfigured', 'previewEndpoint', 'datastore', 'effects', 'currentProvider',
219
246
  'readiness', 'backendHealth', 'vmAccess',
220
247
  'startedAt', 'featureStartedAt', 'attention', 'envOverride',
221
248
  ];