@codeyam-editor/codeyam-editor 0.1.5 → 0.1.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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -8,37 +8,74 @@
8
8
  //
9
9
  // Pure logic only — no I/O, no env reads, no clock.
10
10
 
11
+ const fr = require('./fleet-reachability');
12
+
11
13
  // Classify operator gcloud auth health from the facts server.js gathers via the
12
- // ${GCLOUD} chokepoint. Returns { degraded, reason }: degraded true means the
13
- // operator's own auth is broken (which makes EVERY VM read unreachable), so the
14
- // reset gate must suppress all reboots and the UI must surface a banner.
14
+ // ${GCLOUD} chokepoint. Returns { degraded, kind, reason }: degraded true means
15
+ // the operator's own side is faulted (which makes EVERY VM read unreachable), so
16
+ // the reset gate must suppress all reboots and the UI must surface a banner.
15
17
  // account trimmed `gcloud config list account` output (may be empty)
16
18
  // accountErrMsg non-null when the account-resolve exec itself errored
17
19
  // tokenOk true/false when `auth print-access-token` was run; null when
18
20
  // it was not (account-only phase) — a valid SA account with
19
21
  // tokenOk null is "account looks fine, token not yet proven",
20
22
  // which is NOT degraded, so the caller can proceed to the mint.
23
+ //
24
+ // `kind` DISCRIMINATES the two failures that used to collapse into one banner
25
+ // (2026-07-29): 'auth' means the CREDENTIAL is broken (re-bind the SA), while
26
+ // 'probe-failed' means the INVOCATION never produced an answer (restart the
27
+ // throttled process). Empty output with NO error message is the 'probe-failed'
28
+ // signature — a subprocess starved under cgroup throttling exits without writing
29
+ // anything, whereas a genuine credential failure always produces a message. Both
30
+ // still latch degraded: this adds a discriminator for the operator-facing
31
+ // RECOVERY, it does not weaken the guard. A 'probe-failed' verdict is
32
+ // inconclusive about credentials, so the caller must corroborate with local
33
+ // pressure signals before naming resource exhaustion — the mapping lives in
34
+ // fleet-destroy-safety.classifyDestroyBlock.
21
35
  function classifyOperatorAuth({ account, accountErrMsg, tokenOk } = {}) {
22
36
  const acct = typeof account === 'string' ? account.trim() : '';
23
37
  if (accountErrMsg || !acct) {
24
38
  return {
25
39
  degraded: true,
40
+ kind: accountErrMsg ? 'auth' : 'probe-failed',
26
41
  reason: `operator gcloud could not resolve an account (${accountErrMsg || 'empty output'})`,
27
42
  };
28
43
  }
29
44
  if (!/\.gserviceaccount\.com$/.test(acct)) {
30
45
  return {
31
46
  degraded: true,
47
+ kind: 'auth',
32
48
  reason: `operator gcloud is on a non-service-account '${acct}' — config drift; run scripts/operator/setup-service-account.sh`,
33
49
  };
34
50
  }
35
51
  if (tokenOk === false) {
36
52
  return {
37
53
  degraded: true,
54
+ kind: 'auth',
38
55
  reason: `operator gcloud cannot mint a token for ${acct} (expired/absent credentials); run scripts/operator/setup-service-account.sh`,
39
56
  };
40
57
  }
41
- return { degraded: false, reason: null };
58
+ return { degraded: false, kind: null, reason: null };
59
+ }
60
+
61
+ // Consecutive-failure debounce for the FLEET-WIDE operator-auth-degraded BANNER
62
+ // (vm--flap-tolerant-reachability). Splits the two behaviors of the degraded
63
+ // verdict: the auto-reset SUPPRESSION stays conservative (server.js suppresses a
64
+ // reboot on even ONE failed probe — a broken laptop must never reboot a healthy
65
+ // VM), but a single transient gcloud-resolve hiccup must NOT blank the whole
66
+ // fleet with the scary "every VM reads unreachable" banner (observed 2026-07-01:
67
+ // the exact command succeeded 5/5 right after; the degraded flag self-cleared).
68
+ // The BANNER / all-VMs-unreachable DISPLAY requires `threshold` CONSECUTIVE
69
+ // degraded probes; a single healthy probe resets the streak to 0.
70
+ // prevStreak the consecutive degraded-probe count from the previous check
71
+ // degraded this probe's raw degraded verdict (classifyOperatorAuth.degraded)
72
+ // threshold consecutive degraded probes required before the banner fires
73
+ // Returns { streak, display }: display true only once the streak crosses.
74
+ const OPERATOR_AUTH_DISPLAY_THRESHOLD = 3;
75
+ function debounceOperatorAuthDisplay(prevStreak, degraded, threshold = OPERATOR_AUTH_DISPLAY_THRESHOLD) {
76
+ if (!degraded) return { streak: 0, display: false };
77
+ const streak = (prevStreak || 0) + 1;
78
+ return { streak, display: streak >= threshold };
42
79
  }
43
80
 
44
81
  // True when at least `fleetWideThreshold` VMs are simultaneously past the
@@ -50,7 +87,11 @@ function classifyOperatorAuth({ account, accountErrMsg, tokenOk } = {}) {
50
87
  function isFleetWideUnreachable(vms, threshold, fleetWideThreshold) {
51
88
  let count = 0;
52
89
  for (const vm of Array.isArray(vms) ? vms : []) {
53
- if (vm && !vm.reachable && (vm.unreachableStreak || 0) >= threshold) count++;
90
+ // Only a DEFINITE unreachable verdict counts. A VM whose reachability is
91
+ // unknown (never polled, or held inconclusive) has established nothing, and
92
+ // an unknown must never be evidence in a decision that gates a reboot — the
93
+ // shared invariant in npm/fleet-reachability.js.
94
+ if (vm && fr.isConfirmedUnreachable(vm.reachable) && (vm.unreachableStreak || 0) >= threshold) count++;
54
95
  }
55
96
  return count >= fleetWideThreshold;
56
97
  }
@@ -68,8 +109,105 @@ function classifyIapEditorProbe({ err, stdout } = {}) {
68
109
  return code === '200';
69
110
  }
70
111
 
112
+ // Choose WHICH signal decides a VM's per-poll reachability, and what it says.
113
+ //
114
+ // The laptop and the GCE operator host see a VM through different eyes. A laptop
115
+ // holds SSH `-L` forwards, so the cheap local-port probe (127.0.0.1:PORT_BASE+n)
116
+ // is a true reachability signal there. The GCE operator host has NO such
117
+ // forwards, so that probe fails for EVERY VM on every poll — which climbed each
118
+ // VM's unreachable streak into the vanish-reconcile path and fired a ~15-60s
119
+ // IAP-SSH tunnel probe per VM per cycle. With ~10 VMs those piled up faster than
120
+ // they drained and drove the host past load 25 (diagnosed live 2026-07-19).
121
+ //
122
+ // On GCE the fix is to read the LB editor backend-service health the dashboard
123
+ // ALREADY gathers (one cheap `gcloud ... backend-services get-health` per slow
124
+ // cycle) as the primary verdict: O(1 API call), no tunnel, fleet-scalable.
125
+ //
126
+ // onGce true on the GCE operator host (FLEET_ON_GCE)
127
+ // lbReachable the LB+IAP probe (probeVM) actually reached the VM's
128
+ // editor. Only supplied when PROBE_VIA_LB is on; a
129
+ // null/undefined means "no LB probe on this deployment"
130
+ // (the laptop) and this axis stays silent.
131
+ // backendEditorHealth classifyBackendHealth's verbatim verdict for the editor
132
+ // backend: 'healthy'|'unhealthy'|'unknown'|'absent'
133
+ // (null/undefined before the first slow gather lands)
134
+ // localProbeOk the 127.0.0.1:PORT_BASE+n editor probe answered
135
+ // iapVerdict classifyIapEditorProbe's result, when one is on hand
136
+ //
137
+ // Returns { reachable, source }:
138
+ // reachable true | false | null, where null is INCONCLUSIVE — hold. It must
139
+ // never read as reachable and must never advance an unreachable
140
+ // streak toward an auto-reset. A genuinely-down VM must not read
141
+ // healthy, and a not-yet-known VM must not read dead.
142
+ // source 'lb' | 'backend' | 'local' | 'iap' — which signal decided, for the log.
143
+ //
144
+ // Only 'absent' falls through to the local/IAP path on GCE: a VM with no
145
+ // vm-<N>-editor-bes has no LB signal at all (mid-add / un-wired), and there are
146
+ // few of those at any time, so the occasional IAP probe there is affordable.
147
+ // 'unknown' and the pre-first-gather null deliberately HOLD rather than fall
148
+ // through, because on GCE falling through means a guaranteed-false local probe.
149
+ function classifyReachability({ onGce, backendEditorHealth, localProbeOk, iapVerdict, lbReachable } = {}) {
150
+ if (onGce) {
151
+ // The LB+IAP probe outranks backend health (vm--gce-reachable-flag-from-lb-probe).
152
+ // It is the only signal on the GCE host that actually REACHES the VM's editor:
153
+ // the local-port probe has no SSH `-L` forward there so it always fails, and
154
+ // backendEditorHealth is filled by a slow gather that early-returns for
155
+ // not-yet-reachable VMs — the reachable⇄backend-health deadlock that pinned the
156
+ // dashboard at 0/10 while every probe was in fact succeeding. Only a definite
157
+ // boolean speaks; null/undefined (no LB probe configured — the laptop) falls
158
+ // through to the pre-existing backend/local/IAP ladder unchanged.
159
+ if (lbReachable === true) return { reachable: true, source: 'lb' };
160
+ if (lbReachable === false) return { reachable: false, source: 'lb' };
161
+ if (backendEditorHealth === 'healthy') return { reachable: true, source: 'backend' };
162
+ if (backendEditorHealth === 'unhealthy') return { reachable: false, source: 'backend' };
163
+ // 'unknown', or no gather yet (null/undefined): inconclusive, hold.
164
+ if (backendEditorHealth !== 'absent') return { reachable: null, source: 'backend' };
165
+ // 'absent' falls through to the laptop-shaped local/IAP path below.
166
+ }
167
+ // Laptop path (and GCE's backend-less VMs): the local port probe is primary,
168
+ // exactly as before. The IAP verdict only speaks when the local port is dark.
169
+ if (localProbeOk) return { reachable: true, source: 'local' };
170
+ if (iapVerdict === true) return { reachable: true, source: 'iap' };
171
+ if (iapVerdict === false) return { reachable: false, source: 'iap' };
172
+ return { reachable: false, source: 'local' };
173
+ }
174
+
175
+ // Resolve ONE poll's reachability record from a (possibly inconclusive) verdict
176
+ // plus the prior poll's state. Where classifyReachability answers "which signal
177
+ // decides, and what does it say", this answers "so what does this poll record".
178
+ //
179
+ // This is the safety-critical half of vm--lb-health-reachability-on-gce: an
180
+ // INCONCLUSIVE verdict (reachable null — on GCE, backend health 'unknown' or not
181
+ // yet gathered) must neither invent a reachability nor advance the unreachable
182
+ // streak. The streak is what pollVanishedInstances gates on before it spends an
183
+ // IAP probe and ultimately reboots a VM, so letting an unknown signal increment
184
+ // it would march a VM toward a destructive reset on evidence we do not have.
185
+ // Instead: hold the last-known reachability, and FREEZE the streak.
186
+ //
187
+ // verdict classifyReachability's { reachable, source }
188
+ // prevReachable the previous poll's resolved reachability
189
+ // prevUnreachableStreak the previous poll's consecutive-unreachable count
190
+ //
191
+ // Returns { reachable, unreachableStreak }. A reachable poll zeroes the streak
192
+ // immediately (we never delay clearing a false alarm, only delay raising one).
193
+ function resolveReachabilityPoll({ verdict, prevReachable, prevUnreachableStreak } = {}) {
194
+ const prevStreak = prevUnreachableStreak || 0;
195
+ const raw = verdict ? verdict.reachable : null;
196
+ if (raw === null || raw === undefined) {
197
+ return { reachable: !!prevReachable, unreachableStreak: prevStreak };
198
+ }
199
+ return {
200
+ reachable: !!raw,
201
+ unreachableStreak: raw ? 0 : prevStreak + 1,
202
+ };
203
+ }
204
+
71
205
  module.exports = {
72
206
  classifyOperatorAuth,
207
+ debounceOperatorAuthDisplay,
208
+ OPERATOR_AUTH_DISPLAY_THRESHOLD,
73
209
  isFleetWideUnreachable,
74
210
  classifyIapEditorProbe,
211
+ classifyReachability,
212
+ resolveReachabilityPoll,
75
213
  };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const fr = require('./fleet-reachability');
4
+
3
5
  // Assemble the per-VM wire object the dashboard's /data response carries to the
4
6
  // browser. This is the single source of truth for a card's transport shape: the
5
7
  // card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
@@ -21,7 +23,37 @@ function buildVmEnvelope(v, ctx) {
21
23
  return {
22
24
  n: ctx.n,
23
25
  url: v.url || null,
24
- reachable: !!v.reachable,
26
+ // The VM's public Live Preview URL (<slug>.preview.<domain> in named-tunnel
27
+ // mode, else the local forward). The card reads it as `previewSub` to render
28
+ // the 👁 preview link. Server-computed in server.js and merged onto the poll
29
+ // state — but it was dropped here, so the link was silently absent on EVERY
30
+ // card fleet-wide even when the VM's preview served fine. The exact
31
+ // currentProvider/vmAccess envelope-drift class this module guards against.
32
+ previewUrl: v.previewUrl || null,
33
+ // The app dev-server port the VM serves on (Vite 5173, Next 3000, …),
34
+ // resolved stickily upstream. The card reads it as `appUp` to gate the
35
+ // preview link live. Dropped alongside previewUrl — same silent-omit bug.
36
+ appPort: v.appPort != null ? v.appPort : null,
37
+ // Sticky preview-SERVING signal — "is the VM's dev server up?" — derived
38
+ // from the previewState the editor emits on /api/session-info (forwarding →
39
+ // true, placeholder → false, else null). Reaches every VM over the IAP-LB
40
+ // seam on the GCE operator host, where the old loopback `previewReachable`
41
+ // read false fleet-wide. The card gates the clickable 👁 link on `=== true`.
42
+ // Three-state, never conflating "down" with "unknown".
43
+ previewServing: v.previewServing === true ? true : (v.previewServing === false ? false : null),
44
+ // Sticky preview-INGRESS signal — "is the VM's public preview URL wired?" —
45
+ // from a reachability probe of <slug>.preview.<domain> over the LB, DISTINCT
46
+ // from previewServing. true = wired, false = confirmed dead (→ muted 'public
47
+ // URL not wired' hint), null = unknown (skipped / IAP audience mismatch — a
48
+ // null must never gate the link off). Three-state, same discipline.
49
+ previewIngress: v.previewIngress === true ? true : (v.previewIngress === false ? false : null),
50
+ // `reachable` (three-state) + `reachableSource` (which signal decided, or
51
+ // 'unpolled'). Same tri-state discipline as previewServing/previewIngress
52
+ // above, but delegated to npm/fleet-reachability.js because FIVE consumers
53
+ // ask the same question and the old `!!v.reachable` coercion here is what
54
+ // manufactured the 2026-07-30 fleet-wide phantom outage — 13 cards claiming
55
+ // a definite `false` that no probe had ever established.
56
+ ...fr.normalizeReachability(v),
25
57
  hasSession: !!v.hasSession,
26
58
  // Three-state: true = broker confirmed up, false = confirmed down, null =
27
59
  // editor unreachable so broker state is unknown. Never conflate "down" with
@@ -41,6 +73,14 @@ function buildVmEnvelope(v, ctx) {
41
73
  // instead of active-green 'building'.
42
74
  needsCleanup: !!v.needsCleanup,
43
75
  feature: v.feature || null,
76
+ // The VM's live-claimed plan slug, reported by /api/session-info and merged
77
+ // (sticky) onto the poll state. The card renders it as the what-it's-working-on
78
+ // line when `feature` is null — a VM that has claimed a plan but not yet named
79
+ // a feature (early plan/explore steps) previously showed a blank "no feature
80
+ // (idle)" card. Preferred over the origin claim-ref because this live source
81
+ // never goes stale under a failed claim-ref push. null for a legacy editor
82
+ // that predates the field, or a VM with no claimed plan.
83
+ selectedPlan: v.selectedPlan || null,
44
84
  step: v.step != null ? v.step : null,
45
85
  mode: v.mode || null,
46
86
  label: v.label || null,
@@ -72,6 +112,14 @@ function buildVmEnvelope(v, ctx) {
72
112
  // Auth state + reason + token expiry. Only surfaced when the editor is
73
113
  // reachable (an unreachable VM's cached auth is stale and misleading).
74
114
  providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
115
+ // The silently-broken Live Preview guard: { previewOrigin, expectedOrigin }
116
+ // when the VM's own /api/config proves its preview can never load in a
117
+ // remote browser (a not-same-origin-safe stack stuck on a loopback/subpath
118
+ // origin), null when healthy or unknown. Drives the card's amber "Live
119
+ // Preview unreachable" badge. Gated on `reachable` like providerAuth — an
120
+ // unreachable VM's last-known config is stale, and an unreachable card has
121
+ // louder problems to report than its preview origin.
122
+ previewMisconfigured: (v.reachable && v.previewMisconfigured) || null,
75
123
  effects: v.effects || null,
76
124
  // The VM's live-configured build agent (lowercase provider), reported by
77
125
  // /api/session-info and merged (sticky) onto the poll state. Drives the
@@ -107,6 +155,13 @@ function buildVmEnvelope(v, ctx) {
107
155
  // against — so the line and list rendered empty even when the sidecar held
108
156
  // principals. Defaults to [] for a VM with no list.
109
157
  vmAccess: Array.isArray(v.vmAccess) ? v.vmAccess : [],
158
+ // Per-slot persisted-env override verdict (set by reapplyVmEnv, sticky on
159
+ // poll state): { present, skipped, reason, taggedRepo, setAt, keys }. Drives
160
+ // the card's env-override badge — an "applied" chip listing the masked keys +
161
+ // tagged repo, or a warning when a stale/untagged override is NOT replayed so
162
+ // a months-old override is never invisible again. null when no env is
163
+ // persisted for the slot (or the slot has never been reapplied).
164
+ envOverride: v.envOverride || null,
110
165
  };
111
166
  }
112
167
 
@@ -117,14 +172,14 @@ function buildVmEnvelope(v, ctx) {
117
172
  // card renders means adding it here AND in buildVmEnvelope — the test fails loud
118
173
  // if you do one without the other.
119
174
  const REQUIRED_CARD_FIELDS = [
120
- 'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
121
- 'awaitingInput', 'needsCleanup', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
175
+ 'n', 'url', 'previewUrl', 'appPort', 'previewServing', 'previewIngress', 'reachable', 'reachableSource', 'hasSession', 'brokerLive', 'brokerStatus',
176
+ 'awaitingInput', 'needsCleanup', 'feature', 'selectedPlan', 'step', 'mode', 'label', 'status', 'orphan',
122
177
  'degradedReasons', 'setupSteps', 'attentionReasons',
123
178
  'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
124
179
  'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
125
- 'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
180
+ 'brokerDeath', 'providerAuth', 'previewMisconfigured', 'effects', 'currentProvider',
126
181
  'readiness', 'backendHealth', 'vmAccess',
127
- 'startedAt', 'featureStartedAt', 'attention',
182
+ 'startedAt', 'featureStartedAt', 'attention', 'envOverride',
128
183
  ];
129
184
 
130
185
  module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
@@ -78,13 +78,19 @@ function classifyDescribeError(err, stderr) {
78
78
  // Decide what the startup reap should do for one sidecar. Pure: takes the
79
79
  // decoded sidecar + GCP-existence verdict + (for adds) container-up verdict +
80
80
  // (for up adds) project-wiring verdict, returns a tag the caller dispatches on.
81
- // verdict when dispatch
82
- // 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup
83
- // 'reconcile-add' add up, container up, project wired (or unknown) ensure roster + recordLaunch, then clear
84
- // 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg)
85
- // 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap
86
- // 'clear-only' destroy sidecar but instance still exists just clear the sidecar
87
- // 'leave' gcloud uncertain (exists === null) leave for the next boot
81
+ // verdict when dispatch
82
+ // 'cleanup' destroy gone, OR add gone runDestroyCleanup / runAddCleanup
83
+ // 'reconcile-add' add up, container up, project wired, agent usable ensure roster + recordLaunch, then clear
84
+ // 'half-provisioned' add up, container up, but project NOT wired surface needs-recovery card (finish-wire from cfg)
85
+ // 'provision-incomplete' add up + container up + wired, but agent NOT surface needs-recovery card (the agent
86
+ // usable (CLI missing / auth wedged at unknown) isn't installed/authed — VM5 false-green)
87
+ // 'recover-bootstrap' add instance up but container missing/down re-run cloud:up to finish the half-done bootstrap
88
+ // (runAddRecoverBootstrap first probes the editor
89
+ // directly and diverts to rostering when the
90
+ // bootstrap is actually done and only the LB
91
+ // ingress is missing)
92
+ // 'clear-only' destroy sidecar but instance still exists just clear the sidecar
93
+ // 'leave' gcloud uncertain (exists === null) leave for the next boot
88
94
  // The (exists, containerUp) split distinguishes a finished add from a container-
89
95
  // down half-provisioned one: a dashboard SIGKILL'd mid-`cloud:up` leaves a GCP
90
96
  // instance with no docker/container, which the old (exists-only) matrix wrongly
@@ -99,17 +105,82 @@ function classifyDescribeError(err, stderr) {
99
105
  // for an add whose instance exists; destroy and the instance-gone add path ignore
100
106
  // them. Splitting the matrix out lets the table be exhaustively tested without any
101
107
  // of the side-effects in server.js.
102
- function reapVerdict({ action, exists, containerUp, projectWired }) {
108
+ //
109
+ // The `agentReady` axis (vm: Readiness Verifies Real Usability) closes the VM5
110
+ // false-green: an instance + container that ARE up and wired, but whose build
111
+ // agent isn't usable — the CLI isn't installed, or the auth probe is wedged at
112
+ // `unknown` — is NOT a finished add. Mirrors `projectWired`'s posture EXACTLY:
113
+ // only a POSITIVE not-ready signal (`agentReady === false`) downgrades, to
114
+ // 'provision-incomplete'; `null`/`undefined` (the probe couldn't decide, or an
115
+ // older editor doesn't report it) stays 'reconcile-add' so a flaky probe never
116
+ // strands a healthy VM. Checked AFTER `projectWired` — an unwired box is the more
117
+ // fundamental fault and keeps its own verdict.
118
+ //
119
+ // NOT an axis here (vm--batch-add-must-roster-and-wire-each-vm): "the editor
120
+ // answers on its own port but has no LB ingress". That state is a finished
121
+ // bootstrap missing its wiring, and re-running cloud:up cannot create the
122
+ // missing ingress — so it used to burn the 3-attempt budget and land on
123
+ // 'bootstrap-recovery-exhausted', because the signal being retried on
124
+ // (`lbReachable`) can never pass while the ingress is absent:
125
+ //
126
+ // no ingress → LB 503 → not reachable → recover → still no ingress → exhausted
127
+ //
128
+ // The rescue deliberately lives in server.js's runAddRecoverBootstrap rather
129
+ // than as a verdict here, because it needs a live IAP probe and is only worth
130
+ // paying for on the branch that was about to spend a recovery attempt. Adding it
131
+ // as a `reapVerdict` axis would force every container-down reap to run that
132
+ // probe eagerly just to compute a verdict, for a state that is rare.
133
+ function reapVerdict({ action, exists, containerUp, projectWired, agentReady, lbReachable }) {
103
134
  if (exists === null) return 'leave';
104
135
  if (action === 'destroy') return exists ? 'clear-only' : 'cleanup';
105
136
  if (action === 'add') {
106
137
  if (!exists) return 'cleanup';
138
+ // The `lbReachable` axis (vm--gce-reachable-flag-from-lb-probe) closes the
139
+ // false-orphan: a VM whose editor ANSWERS over the LB is provisioned by
140
+ // definition — its add finished, whatever a stale `add-<n>.json` sidecar
141
+ // left lying around implies. Without this, the container probe below could
142
+ // report "container down" for a VM that is demonstrably up and serving,
143
+ // driving recover-bootstrap → "exceeded 3 attempts" → a
144
+ // needs-manual-recovery card for a perfectly healthy box.
145
+ // Inverted posture vs projectWired/agentReady, and deliberately so: those
146
+ // axes let only a NEGATIVE signal downgrade, this one lets only a POSITIVE
147
+ // signal promote. `false`/`null`/`undefined` (probe said no, couldn't
148
+ // decide, or never ran) all fall through to the unchanged ladder below, so
149
+ // this can never strand a VM — it can only rescue one.
150
+ if (lbReachable === true) return 'reconcile-add';
107
151
  if (!containerUp) return 'recover-bootstrap';
108
- return projectWired === false ? 'half-provisioned' : 'reconcile-add';
152
+ if (projectWired === false) return 'half-provisioned';
153
+ if (agentReady === false) return 'provision-incomplete';
154
+ return 'reconcile-add';
109
155
  }
110
156
  return 'leave';
111
157
  }
112
158
 
159
+ // Classify the startup reap's LB rescue signal from a best-effort LB+IAP editor
160
+ // probe. Pure: server.js runs the HTTP probe and hands the raw outcome here, the
161
+ // same split classifyProjectWiring follows, so the rule is unit-testable without
162
+ // a live VM or an LB.
163
+ //
164
+ // Deliberately tri-state and ASYMMETRIC — it returns `true` or `null`, NEVER
165
+ // `false`:
166
+ // - probeViaLb false (a laptop has no LB to ask) → null (no opinion)
167
+ // - err present (threw, auth fault, timeout) → null (no opinion)
168
+ // - the editor answered → true (add is finished)
169
+ // - the editor did not answer → null (NOT false)
170
+ //
171
+ // That last line is the load-bearing one. A silent probe is not evidence a VM is
172
+ // dead — the LB may be unwired mid-add, or the IAP token not yet minted. Since
173
+ // reapVerdict promotes on `true` and ignores everything else, returning null
174
+ // leaves the pre-existing container/wiring ladder in charge. Returning `false`
175
+ // here would be indistinguishable to reapVerdict, but it would be a lie about
176
+ // what the probe proved, and the next person to add an axis would read it as
177
+ // evidence. The invariant this pins: this signal can rescue a VM, never strand one.
178
+ function classifyLbReachableForReap({ probeViaLb, reachable, err } = {}) {
179
+ if (!probeViaLb) return null;
180
+ if (err) return null;
181
+ return reachable ? true : null;
182
+ }
183
+
113
184
  // Classify whether a VM's editor `/workspace` is actually wired to a client
114
185
  // project, from a best-effort probe of two signals: the git remote URL and
115
186
  // whether `package.json` carries a real `name`. Pure: server.js runs the SSH +
@@ -266,6 +337,132 @@ function reattachNextStep(verdict) {
266
337
  return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
267
338
  }
268
339
 
340
+ // May the PERIODIC stranded-reconcile pass drive a recover/roster probe for this
341
+ // VM, or is a provision already running that a second cloud:up would collide
342
+ // with? This is the guard the boot re-attach path has always had and the
343
+ // stranded path never did — the 2026-08-07 asymmetry that destroyed VM-4/VM-5.
344
+ //
345
+ // On that day an `add` job badge read `error` while its detached `cloud:up`
346
+ // child was alive and healthy (a source-build bootstrap runs ~40 minutes;
347
+ // `npm install` alone took 25m). `pollStrandedInstances` builds `activeAddNs`
348
+ // from BADGE state and deliberately excludes an `error` add so the bounded
349
+ // bootstrap-recovery budget can retry on a timer — so the VM read
350
+ // running-but-unrostered, crossed STRANDED_ADOPT_THRESHOLD in ~3 minutes, and
351
+ // the pass launched a SECOND cloud:up on top of the live one. The two raced on
352
+ // `docker compose up -d`, the loser hit `Conflict. The container name
353
+ // "/codeyam-editor-editor-1" is already in use`, and its abort handler deleted
354
+ // the instance. The badge is a derived, lossy signal that was wrong for 30
355
+ // minutes straight; LIVENESS is the ground truth, so this gate reads exactly
356
+ // what `reattachAdd` reads — the exit marker plus a `kill -0` of the recorded
357
+ // pid — and composes `reattachVerdict`/`reattachNextStep` rather than forking a
358
+ // parallel matrix.
359
+ //
360
+ // 'watch' no marker + a live child → the provision is genuinely in flight.
361
+ // Re-attach and watch (mirroring
362
+ // runStartupReap); NEVER provision.
363
+ // 'defer' no marker, no live child, and no pid was EVER recorded, inside the
364
+ // ambiguity window → we cannot tell whether a child is
365
+ // running. Do nothing this tick.
366
+ // 'probe' everything else → existing behavior: drive the gcloud
367
+ // probe (reconcileViaGcloud →
368
+ // reapVerdict).
369
+ //
370
+ // The 'defer' rung exists because `childAlive` is FALSE-NEGATIVE-PRONE on one
371
+ // specific path: when `strandedReconcileMeta` finds no sidecar it reconstructs
372
+ // cfg from launch history and persists `{ cfg }` with NO pid, so `isPidAlive`
373
+ // reads false for a VM whose child may well be alive — and, worse, that pid-less
374
+ // sidecar is what every later tick reads back, blinding the guard permanently.
375
+ // `pidRecorded` separates "we knew the pid and the process is gone" (real
376
+ // evidence → probe) from "we never had a pid" (absence of evidence → defer).
377
+ // Erring toward NOT provisioning is the asymmetric-cost choice: waiting one more
378
+ // 60s tick costs nothing, being wrong costs a destroyed VM.
379
+ //
380
+ // The window is what keeps 'defer' from becoming a permanent refusal that would
381
+ // break stranded adoption outright: once more than `ambiguityWindowMs` has
382
+ // elapsed since we first saw the pid-less sidecar, no plausible provision child
383
+ // is still running, so the probe proceeds and the self-heal path is preserved.
384
+ // An unknown/unstamped elapsed is treated as "just noticed" (defer), again the
385
+ // safe direction; a caller that supplies no window at all opts out entirely and
386
+ // keeps the old probe-always behavior.
387
+ //
388
+ // Pure, so the whole matrix is testable without a VM: server.js maps the verdict
389
+ // to its side effect.
390
+ function strandedRecoverGate({
391
+ markerPresent,
392
+ exitCode,
393
+ childAlive,
394
+ pidRecorded,
395
+ sinceMs,
396
+ ambiguityWindowMs,
397
+ } = {}) {
398
+ // A live child is a live child — reuse the existing classifier so 'running'
399
+ // keeps meaning exactly what the boot path means by it.
400
+ if (reattachNextStep(reattachVerdict({ markerPresent, exitCode, childAlive })) === 'watch') {
401
+ return 'watch';
402
+ }
403
+ // A marker is durable proof the child reached its EXIT trap: nothing is in
404
+ // flight, so the probe is safe. (Which probe outcome follows — roster,
405
+ // recover-bootstrap, surface-error — stays reapVerdict's call, not ours.)
406
+ if (markerPresent) return 'probe';
407
+ // We knew the pid and `kill -0` says it is gone: real evidence, so probe.
408
+ if (pidRecorded) return 'probe';
409
+ const window = Number(ambiguityWindowMs);
410
+ if (!Number.isFinite(window) || window <= 0) return 'probe'; // caller opted out
411
+ const elapsed = Number(sinceMs);
412
+ if (!Number.isFinite(elapsed)) return 'defer'; // unstamped — treat as just-noticed
413
+ return elapsed < window ? 'defer' : 'probe';
414
+ }
415
+
416
+ // Is an `add` genuinely IN FLIGHT for this VM right now?
417
+ //
418
+ // Extracted from pollVanishedInstances, where the inline expression was
419
+ // `addPending.has(n) || !!(state.jobs[n] && state.jobs[n].action === 'add')` —
420
+ // whose second clause is also true for a TERMINAL (`done` / `error`) add job.
421
+ // That permanently marks the VM busy, so `reconcileRosteredVm` can never return
422
+ // 'cleanup' and a rostered VM whose instance is gone can never be auto-reaped.
423
+ // Live evidence from the same 2026-08-07 incident: VM-5's instance was deleted
424
+ // at ~12:46 and its card logged `vanish-reconcile: VM-5 unreachable …` once a
425
+ // minute for 50 consecutive polls, only clearing after a manual `clear-badge`
426
+ // deleted the terminal job record.
427
+ //
428
+ // The on-disk sidecar clause is already correct and is preserved: a sidecar is
429
+ // removed on the add's success path, so its presence really does mean in-flight.
430
+ // The job clause now filters on job STATE the same way `activeAddNs` and
431
+ // `pollStartupProbes` do. Pure so the caller's computation is directly
432
+ // assertable — the bug was never in `reconcileRosteredVm`'s contract, it was in
433
+ // what this expression fed it.
434
+ const IN_FLIGHT_ADD_STATES = ['running', 'queued', 'recovering'];
435
+
436
+ function hasInFlightAdd({ sidecarPending, job } = {}) {
437
+ if (sidecarPending) return true;
438
+ if (!job || job.action !== 'add') return false;
439
+ return IN_FLIGHT_ADD_STATES.includes(job.state);
440
+ }
441
+
442
+ // WHICH of reconcileRosteredVm's two distinct 'leave' causes fired, as a tag the
443
+ // caller renders. Pure so the distinction is directly assertable.
444
+ //
445
+ // 'leave' is returned both when the GCP probe was INCONCLUSIVE (exists null /
446
+ // undefined — absence of evidence, never act) and when the instance is
447
+ // CONFIRMED gone but the VM is busy. The old single log line conflated them into
448
+ // a self-contradiction — it announced "instance-gone unconfirmed (exists=false)"
449
+ // when exists=false IS the confirmation — which cost hours of code-reading
450
+ // during the 2026-08-07 incident to work out which branch was actually firing.
451
+ //
452
+ // 'probe-inconclusive' exists is null/undefined — retry next tick
453
+ // 'live-session' instance confirmed gone but a session is live
454
+ // 'in-flight-add' instance confirmed gone but an add is in flight
455
+ // null not a 'leave' case at all
456
+ //
457
+ // Mirrors reconcileRosteredVm's own precedence (exists-uncertainty first, then
458
+ // hasSession, then hasPendingAdd) so the tag can never disagree with the verdict.
459
+ function vanishLeaveReason({ exists, hasSession, hasPendingAdd } = {}) {
460
+ if (exists === null || exists === undefined) return 'probe-inconclusive';
461
+ if (hasSession) return 'live-session';
462
+ if (hasPendingAdd) return 'in-flight-add';
463
+ return null;
464
+ }
465
+
269
466
  // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
270
467
  // recoveries ALREADY persisted on the sidecar, return the next attempt number,
271
468
  // whether the reap should proceed (re-run cloud:up) or treat the VM as
@@ -414,6 +611,38 @@ function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetH
414
611
  return "keep";
415
612
  }
416
613
 
614
+ // The bootstrap-recovery ladder step the startup reap runs for a VM whose first
615
+ // cloud:up did not finish: decide (via recoverBootstrapDecision) whether another
616
+ // attempt is allowed, then either flag the VM exhausted (leave the sidecar for
617
+ // manual action) or clear any stale orphan flag and re-run cloud:up via `addVm`.
618
+ // The dashboard's `state` and `addVm` are injected so this stays a pure,
619
+ // importable unit — `log`/`err` default to the console but are overridable in
620
+ // tests. Behavior mirrors the previous inline server.js definition exactly.
621
+ function continueBootstrapRecovery(
622
+ n,
623
+ meta,
624
+ cfg,
625
+ { state, addVm, log = console.log, err = console.error } = {},
626
+ ) {
627
+ const { attempt, proceed, max } = recoverBootstrapDecision({
628
+ recoverAttempts: meta.recoverAttempts,
629
+ });
630
+ if (!proceed) {
631
+ err(
632
+ `startup-reap: VM-${n} exceeded ${max} bootstrap-recovery attempts — leaving sidecar for manual action`,
633
+ );
634
+ state.orphans[n] = { reason: "bootstrap-recovery-exhausted", attempts: attempt - 1 };
635
+ return;
636
+ }
637
+ log(
638
+ `startup-reap: VM-${n} re-running cloud:up to finish bootstrap (recovery attempt ${attempt}/${max})`,
639
+ );
640
+ // A prior exhausted/no-cfg run may have flagged this VM; clear it now that a
641
+ // fresh attempt is starting so the card moves to `recovering`, not stale red.
642
+ delete state.orphans[n];
643
+ addVm(n, cfg, { recovering: true, recoverAttempt: attempt });
644
+ }
645
+
417
646
  module.exports = {
418
647
  parsePendingJobFilename,
419
648
  persistPendingJob,
@@ -424,7 +653,9 @@ module.exports = {
424
653
  reconcileRosteredVm,
425
654
  orphanedAddsToReconcile,
426
655
  classifyProjectWiring,
656
+ classifyLbReachableForReap,
427
657
  recoverBootstrapDecision,
658
+ continueBootstrapRecovery,
428
659
  exitMarkerName,
429
660
  parseExitMarker,
430
661
  readExitMarker,
@@ -432,4 +663,7 @@ module.exports = {
432
663
  isSignalKillExit,
433
664
  reattachVerdict,
434
665
  reattachNextStep,
666
+ strandedRecoverGate,
667
+ hasInFlightAdd,
668
+ vanishLeaveReason,
435
669
  };