@codeyam-editor/codeyam-editor 0.1.4 → 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 (97) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud-branch-switch.js +1 -1
  3. package/npm/cloud.js +3166 -150
  4. package/npm/codesign-plan.js +68 -0
  5. package/npm/container.js +3 -0
  6. package/npm/disk-pressure.js +78 -0
  7. package/npm/editor-dev.js +96 -15
  8. package/npm/editor.js +85 -37
  9. package/npm/empty-project-dir.js +92 -7
  10. package/npm/fleet-add-failure.js +252 -0
  11. package/npm/fleet-asset-version.js +37 -0
  12. package/npm/fleet-auth-health.js +213 -0
  13. package/npm/fleet-auth.js +6 -1
  14. package/npm/fleet-build-data.js +109 -5
  15. package/npm/fleet-cleanup.js +232 -16
  16. package/npm/fleet-destroy-safety.js +272 -0
  17. package/npm/fleet-early-roster.js +87 -0
  18. package/npm/fleet-effect-poll.js +110 -0
  19. package/npm/fleet-effect-status.js +33 -32
  20. package/npm/fleet-env.js +86 -0
  21. package/npm/fleet-ghcr-secret.js +129 -0
  22. package/npm/fleet-git-maintenance.js +142 -0
  23. package/npm/fleet-host-op-limiter.js +68 -0
  24. package/npm/fleet-host.js +74 -0
  25. package/npm/fleet-iap-token.js +165 -0
  26. package/npm/fleet-job-badge.js +1 -1
  27. package/npm/fleet-launchd.js +171 -0
  28. package/npm/fleet-live-status.js +155 -0
  29. package/npm/fleet-memory.js +245 -9
  30. package/npm/fleet-plans.js +233 -4
  31. package/npm/fleet-pool.js +75 -16
  32. package/npm/fleet-prewarm.js +241 -0
  33. package/npm/fleet-project-drift.js +58 -0
  34. package/npm/fleet-public-url.js +394 -0
  35. package/npm/fleet-queue.js +95 -1
  36. package/npm/fleet-reachability-debounce.js +69 -0
  37. package/npm/fleet-reachability.js +80 -0
  38. package/npm/fleet-reconcile.js +43 -1
  39. package/npm/fleet-reset.js +330 -13
  40. package/npm/fleet-roster.js +6 -8
  41. package/npm/fleet-running-plan.js +85 -0
  42. package/npm/fleet-self-update.js +142 -0
  43. package/npm/fleet-session-token-cache.js +65 -0
  44. package/npm/fleet-setup-steps.js +112 -0
  45. package/npm/fleet-sleep-status.js +33 -0
  46. package/npm/fleet-status-refs.js +96 -0
  47. package/npm/fleet-strand.js +181 -0
  48. package/npm/fleet-stuck-status.js +117 -0
  49. package/npm/fleet-vm-access.js +404 -0
  50. package/npm/fleet-vm-probe-target.js +282 -0
  51. package/npm/fleet-vm-readiness.js +237 -0
  52. package/npm/fleet-vm-slugs.js +116 -0
  53. package/npm/google-ingress.js +226 -0
  54. package/npm/ingress-invoke.js +770 -0
  55. package/npm/launch-config.js +386 -9
  56. package/npm/scenario-check.js +880 -37
  57. package/npm/scenario-handlers.js +154 -30
  58. package/npm/scenario-interactivity.js +145 -6
  59. package/npm/scenario-issues.js +13 -1
  60. package/npm/scenario-metrics.js +116 -1
  61. package/npm/scenario-mocks.js +144 -21
  62. package/npm/scenario-playwright.js +649 -38
  63. package/npm/script-drift.js +152 -0
  64. package/npm/utils.js +365 -52
  65. package/npm/vm-urls.js +69 -8
  66. package/package.json +5 -5
  67. package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
  68. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
  69. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  70. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-Chgdf3PW.js} +1 -1
  71. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +1 -0
  72. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
  73. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  74. package/ui/dist/assets/ScenarioDataPanelFullPage-BLqRCxQX.js +1 -0
  75. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
  76. package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
  77. package/ui/dist/assets/index-BygCiJSb.css +1 -0
  78. package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
  79. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  80. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  81. package/ui/dist/assets/useEvents-S3Qk1Y_d.js +1 -0
  82. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
  83. package/ui/dist/codeyam-mark.svg +4 -0
  84. package/ui/dist/codeyam-wordmark.svg +5 -0
  85. package/ui/dist/index.html +4 -4
  86. package/ui/dist/open-source/codeyam-counter.png +0 -0
  87. package/ui/dist/open-source/el-carot.png +0 -0
  88. package/ui/dist/open-source/tabcommand.png +0 -0
  89. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  90. package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
  91. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  92. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  93. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  94. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  95. package/ui/dist/assets/index-BGqairFV.css +0 -1
  96. package/ui/dist/assets/react-CSS0HapR.js +0 -28
  97. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+ // Where the fleet dashboard sends a per-VM editor-API probe, and how to read
3
+ // the answer.
4
+ //
5
+ // The dashboard gets ALL per-VM live data — reachability, feature, step,
6
+ // selectedPlan, credential health, preview — by probing each VM's editor API.
7
+ // On a laptop that probe goes to `127.0.0.1:PORT_BASE+N`, which is fed by an SSH
8
+ // `-L` forward the vm-watchdog creates. On the GCE operator host those forwards
9
+ // cannot exist: the watchdog runs as root, the fleet VMs use metadata SSH keys
10
+ // (OS Login is OFF) and refuse root login, so `gcloud compute ssh` fails
11
+ // `Permission denied (publickey)`. No forwards ⇒ dead local ports ⇒ every card
12
+ // reads "unreachable" with no progress.
13
+ //
14
+ // On GCE we therefore probe each VM through the LB hostname it is already wired
15
+ // to (`https://vm-<N>.editor.<domain>`), authenticated with an IAP identity
16
+ // token minted by the operator service account. Same paths, same downstream
17
+ // parsing — only the transport changes.
18
+ //
19
+ // Pure logic only — no I/O, no env reads, no clock — so it stays unit-testable
20
+ // in isolation (npm/fleet-vm-probe-target.test.js), exactly like vm-urls.js.
21
+
22
+ // Loopback host for the laptop probe. The SSH `-L` forward binds here, so the
23
+ // probe must too — 'localhost' could resolve to ::1 and miss an IPv4-only bind.
24
+ const LOCAL_HOST = '127.0.0.1';
25
+
26
+ /** Resolve where a per-VM editor-API probe should be sent.
27
+ *
28
+ * Returns a descriptor the caller dispatches on:
29
+ * { mode: 'lb', url, headers, authMissing } — GCE: absolute LB URL
30
+ * { mode: 'local', host, port, path, headers } — laptop: forwarded port
31
+ *
32
+ * `authMissing` is true when a GCE probe has no IAP token to attach. The caller
33
+ * must NOT treat the resulting response as reachable — an unauthenticated
34
+ * request to an IAP-fronted backend gets a 302 to the Google login page, which
35
+ * is "auth misconfigured", not "the VM is up". See classifyProbeResponse.
36
+ *
37
+ * `sessionToken` (optional): the fail-closed editor requires each VM's own
38
+ * editor session token in `Authorization` on every gated control-API request.
39
+ * When one is supplied, it rides in `Authorization` and the IAP token moves to
40
+ * `Proxy-Authorization` (IAP honors it there), so both credentials are present
41
+ * without colliding on `Authorization`. When it is absent, header shape is
42
+ * byte-for-byte today's behavior (IAP in `Authorization`), keeping the open
43
+ * `/api/session-info` reachability probe unchanged.
44
+ */
45
+ function resolveVmProbeTarget({ onGce, url, n, portBase, path, iapToken, sessionToken } = {}) {
46
+ const probePath = path == null ? '/' : String(path);
47
+ if (!probePath.startsWith('/')) {
48
+ throw new Error(`probe path must start with "/", got: ${probePath}`);
49
+ }
50
+
51
+ if (!onGce) {
52
+ const base = Number(portBase);
53
+ const num = Number(n);
54
+ if (!Number.isInteger(base) || !Number.isInteger(num)) {
55
+ throw new Error('local probe requires integer portBase and n');
56
+ }
57
+ return { mode: 'local', host: LOCAL_HOST, port: base + num, path: probePath, headers: {} };
58
+ }
59
+
60
+ // GCE: the LB URL is the ONLY way in. A VM with no derived URL (legacy
61
+ // quick-tunnel mode, or a roster entry the URL derivation skipped) has no
62
+ // reachable address on this host, so fail loud rather than silently falling
63
+ // back to a local port that is guaranteed dead.
64
+ const origin = url == null ? '' : String(url).trim().replace(/\/+$/, '');
65
+ if (origin === '') {
66
+ throw new Error(`VM-${n} has no LB URL to probe (FLEET_DOMAIN not configured?)`);
67
+ }
68
+
69
+ const token = iapToken == null ? '' : String(iapToken).trim();
70
+ const session = sessionToken == null ? '' : String(sessionToken).trim();
71
+ // With a session token the fail-closed editor's own credential owns
72
+ // `Authorization`; IAP still fronts the LB, so its token moves to
73
+ // `Proxy-Authorization`. Without one, keep exactly today's IAP-in-
74
+ // `Authorization` shape. `authMissing` stays keyed on the IAP token alone —
75
+ // IAP is still required to reach the LB even when a session token is present.
76
+ let headers;
77
+ if (session !== '') {
78
+ headers = { Authorization: `Bearer ${session}` };
79
+ if (token !== '') headers['Proxy-Authorization'] = `Bearer ${token}`;
80
+ } else {
81
+ headers = token === '' ? {} : { Authorization: `Bearer ${token}` };
82
+ }
83
+ return {
84
+ mode: 'lb',
85
+ url: origin + probePath,
86
+ headers,
87
+ authMissing: token === '',
88
+ };
89
+ }
90
+
91
+ // The one open control-API endpoint: `/api/session-info` answers WITHOUT the
92
+ // editor's session token — it is the reachability probe. Every other control-API
93
+ // path is gated under fail-closed auth, so the dashboard must attach the VM's
94
+ // session token or get a 401. Kept here (pure, tested) rather than inline in the
95
+ // caller so the open-vs-gated boundary has one authoritative home.
96
+ const OPEN_PROBE_PATHS = new Set(['/api/session-info']);
97
+
98
+ /** True when a probe path needs the VM's editor session token attached.
99
+ *
100
+ * Only the open reachability probe (`/api/session-info`) is exempt; every other
101
+ * control-API path is gated under fail-closed auth. A query string is ignored so
102
+ * `/api/session-info?foo` still reads as the open path. */
103
+ function pathNeedsSessionToken(apiPath) {
104
+ const p = (apiPath == null ? '' : String(apiPath)).split('?')[0];
105
+ return p !== '' && !OPEN_PROBE_PATHS.has(p);
106
+ }
107
+
108
+ // Hostname IAP redirects an unauthenticated request to. Matching this is what
109
+ // separates "the token is missing/expired" from "the VM is genuinely down" —
110
+ // both would otherwise read as a non-200 and blank the card identically.
111
+ const IAP_LOGIN_HOST = 'accounts.google.com';
112
+
113
+ /** Classify a probe response into the three outcomes the dashboard cares about.
114
+ *
115
+ * 'ok' — 200; caller still has to parse the body.
116
+ * 'auth-misconfigured' — IAP rejected or challenged us (302→login, 401, 403).
117
+ * The VM may be perfectly healthy; OUR credential is
118
+ * the problem, so this must never render as reachable
119
+ * and must never feed a reset decision.
120
+ * 'unreachable' — anything else: the VM/LB backend is not serving.
121
+ */
122
+ function classifyProbeResponse({ status, location } = {}) {
123
+ const code = Number(status);
124
+ if (code === 200) return 'ok';
125
+ if (code === 401 || code === 403) return 'auth-misconfigured';
126
+ if (code >= 300 && code < 400) {
127
+ const to = location == null ? '' : String(location);
128
+ return to.includes(IAP_LOGIN_HOST) ? 'auth-misconfigured' : 'unreachable';
129
+ }
130
+ return 'unreachable';
131
+ }
132
+
133
+ /** Name the LIKELY cause behind an 'auth-misconfigured' probe, for logs only.
134
+ *
135
+ * 401 and 403 both mean "our credential is the problem", so they deliberately
136
+ * share one outcome — but they have opposite fixes, and telling them apart
137
+ * previously cost a live `curl` session against the backend (2026-07-20):
138
+ *
139
+ * 401 — the fail-closed editor rejected our session token. IAP now rides in
140
+ * Proxy-Authorization, so a 401 from the backend means the VM's own
141
+ * editor session token in Authorization was missing, stale, or wrong —
142
+ * the dashboard invalidates its cached token and self-heals next poll.
143
+ * 403 — the token is valid but the caller is not authorized. The automation SA
144
+ * is missing roles/iap.httpsResourceAccessor on that backend service.
145
+ *
146
+ * Returns null for any other status, so callers can log unconditionally.
147
+ * Diagnostic only — this never feeds a reachability or reset decision. */
148
+ function describeAuthMisconfig(status) {
149
+ const code = Number(status);
150
+ if (code === 401) {
151
+ return "session token rejected — the fail-closed editor requires this VM's " +
152
+ 'own editor session token in Authorization; the dashboard fetch was ' +
153
+ 'missing/stale (it invalidates the cached token and re-fetches next poll)';
154
+ }
155
+ if (code === 403) {
156
+ return 'missing accessor — the automation service account lacks ' +
157
+ 'roles/iap.httpsResourceAccessor on this backend service ' +
158
+ '(re-grant: bash scripts/gcp/iap-access.sh <N> grant)';
159
+ }
160
+ return null;
161
+ }
162
+
163
+ /** Map a probe outcome (+ optional transport reason) to the per-VM card shape
164
+ * the dashboard merges into `state.vms[n]`.
165
+ *
166
+ * The rule this encodes: an 'auth-misconfigured' outcome is NOT reachable, and
167
+ * stays distinguishable from a dead VM. Collapsing the two would either render
168
+ * a broken credential as a healthy VM, or let our own expired token look like N
169
+ * dead boxes and feed a reset decision. Anything unrecognized fails closed to
170
+ * unreachable rather than leaking a truthy `reachable`. */
171
+ function probeOutcomeToInfo(outcome, error) {
172
+ if (outcome === 'ok') return { reachable: true };
173
+ if (outcome === 'auth-misconfigured') {
174
+ return { reachable: false, error: 'auth-misconfigured', authMisconfigured: true };
175
+ }
176
+ return error ? { reachable: false, error } : { reachable: false };
177
+ }
178
+
179
+ /** Merge a parsed `/api/session-info` body into the per-VM card shape.
180
+ *
181
+ * The contract, in one place instead of inlined in the dashboard's probe:
182
+ * • an unreachable card never takes a body (a credential fault or a dead VM
183
+ * must not gain fields from whatever the transport happened to return);
184
+ * • a reachable card takes the body ONLY when it is a usable object.
185
+ *
186
+ * That second rule used to be accidental rather than stated. The expression was
187
+ * `info.reachable ? { ...info, ...json } : info`, and spreading `null` (or an
188
+ * array, or a scalar) is a silent no-op — so an unusable body produced a card
189
+ * that read reachable with `clientProject` and `editorIdentity` absent, which is
190
+ * indistinguishable from a VM that genuinely reports neither. The resulting card
191
+ * shape is deliberately UNCHANGED by making the rule explicit; what changes is
192
+ * that the caller can now see the fault (probeBodyFault) and log it instead of
193
+ * discovering it days later through a blank card. Pure. */
194
+ function mergeSessionInfoBody(info, json) {
195
+ if (!info || !info.reachable) return info;
196
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return info;
197
+ return { ...info, ...json };
198
+ }
199
+
200
+ /** Name the fault when a probe SUCCEEDED at the transport layer but its body is
201
+ * not a usable object, else null.
202
+ *
203
+ * This is the hole that cost a full diagnostic session on 2026-07-29. The card
204
+ * merge is `info.reachable ? { ...info, ...json } : info`, and spreading `null`
205
+ * (or an array, or a JSON scalar) is a silent no-op — so a VM answering 200 with
206
+ * an unusable body produced a card that read REACHABLE with every session-info
207
+ * field absent (`clientProject` and `editorIdentity` null), and emitted no log
208
+ * line at all. `stickyClientProject` then hid it for as long as a previously
209
+ * polled value survived in memory, so the fault only surfaced after a dashboard
210
+ * restart cleared the sticky cache.
211
+ *
212
+ * A 'bad-json' transport error is the sibling case: the body was unparseable, so
213
+ * the probe already fails closed to unreachable — correct, but equally silent.
214
+ * Callers pass that through here too so both paths get one loud warning.
215
+ *
216
+ * Returns a short, log-safe fault name:
217
+ * 'bad-json' — body did not parse (caller passes error === 'bad-json')
218
+ * 'null-body' — parsed to JSON null
219
+ * 'array-body' — parsed to an array
220
+ * '<type>-body' — parsed to a scalar (number/string/boolean)
221
+ * and null when the body is a usable object (the normal case) or the outcome was
222
+ * never 'ok' (already classified by probeOutcomeToInfo). Pure. */
223
+ function probeBodyFault(outcome, json, error) {
224
+ if (error === 'bad-json') return 'bad-json';
225
+ if (outcome !== 'ok') return null;
226
+ if (json === null || json === undefined) return 'null-body';
227
+ if (Array.isArray(json)) return 'array-body';
228
+ if (typeof json !== 'object') return `${typeof json}-body`;
229
+ return null;
230
+ }
231
+
232
+ /** The log line for a probe-body fault, naming the VM, the fault and the HTTP
233
+ * status — the three things that were missing when this failure mode cost a
234
+ * full diagnostic session with zero log output to work from.
235
+ *
236
+ * A missing status renders as `?` rather than `undefined`: the transport error
237
+ * paths (timeout, socket error) never saw a response, and `HTTP undefined` reads
238
+ * like a bug in the logger instead of an absent status.
239
+ *
240
+ * Diagnostic only — this never feeds a reachability decision, same contract as
241
+ * its sibling describeAuthMisconfig. Pure. */
242
+ function describeProbeBodyFault(n, fault, status) {
243
+ const code = status == null || status === '' ? '?' : String(status);
244
+ return `probe VM-${n} /api/session-info: ${fault} (HTTP ${code}) — ` +
245
+ 'card identity (clientProject/editorIdentity) absent this poll';
246
+ }
247
+
248
+ /** Per-VM, per-fault throttle for probe-fault warnings.
249
+ *
250
+ * The poll cycle runs every ~3s across the whole fleet, so warning on every
251
+ * faulted probe would bury the log. Throttling per (VM, fault) keeps a
252
+ * persistent fault to one line per interval while still surfacing a NEW fault on
253
+ * a different VM immediately.
254
+ *
255
+ * Deterministic: the caller supplies `now`, so no clock is read here and the
256
+ * behavior is unit-testable. */
257
+ function createProbeFaultThrottle(intervalMs = 300000) {
258
+ const last = new Map();
259
+ return {
260
+ shouldWarn(n, fault, now) {
261
+ const key = `${n}:${fault}`;
262
+ const prev = last.get(key);
263
+ if (prev != null && now - prev < intervalMs) return false;
264
+ last.set(key, now);
265
+ return true;
266
+ },
267
+ };
268
+ }
269
+
270
+ module.exports = {
271
+ LOCAL_HOST,
272
+ IAP_LOGIN_HOST,
273
+ resolveVmProbeTarget,
274
+ pathNeedsSessionToken,
275
+ classifyProbeResponse,
276
+ describeAuthMisconfig,
277
+ probeOutcomeToInfo,
278
+ mergeSessionInfoBody,
279
+ probeBodyFault,
280
+ describeProbeBodyFault,
281
+ createProbeFaultThrottle,
282
+ };
@@ -0,0 +1,237 @@
1
+ 'use strict';
2
+ // Pure readiness derivation for the Fleet Dashboard's per-VM card
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the merged
4
+ // poll state `v` and the derived `ctx` the card builder already computes,
5
+ // collapse the VM into a single readiness label the card renders. No I/O —
6
+ // server.js owns the polling (including the slow-cadence LB backend-health
7
+ // probe that fills `v.backendHealth`).
8
+ //
9
+ // The bug this fixes: a VM card rendered as idle with a CLICKABLE editor URL the
10
+ // moment the VM was rostered + SSH-tunnel-reachable, even when its public LB URL
11
+ // (vm-<N>.editor.<domain>) would return Envoy "no healthy upstream" (503). The
12
+ // card reflected only SSH-tunnel reachability to :14199, never whether the load
13
+ // balancer actually routes to a HEALTHY backend. Two situations cause the 503,
14
+ // and the card had zero visibility into either:
15
+ //
16
+ // transient half-wire — the editor is serving but the LB health check hasn't
17
+ // marked the backend HEALTHY yet during cold start. It
18
+ // resolves on its own; the card should say 'starting'.
19
+ // permanent half-wire — the per-VM LB backend service was never created (e.g.
20
+ // the Add ran under a service account lacking
21
+ // compute.backendServices.create), so the URL 503s
22
+ // forever until re-wired. The card should say
23
+ // 'half-wired', visibly distinct from 'starting'.
24
+ //
25
+ // SSH reachability (v.reachable from probeVM over the tunnel) is necessary but
26
+ // not sufficient: the tunnel is up in BOTH 503 cases. Only the LB backend health
27
+ // (gcloud compute backend-services get-health, gathered into v.backendHealth)
28
+ // distinguishes ready / starting / half-wired. The editor's own /healthz can't
29
+ // see the half-wire, so it is not enough on its own.
30
+ //
31
+ // 'provisioning' — an Add/recover job is in flight; the VM is still being
32
+ // built, so judging its public-URL readiness is premature.
33
+ // 'unreachable' — the editor did not answer over the tunnel. Nothing
34
+ // downstream (broker, LB health) is meaningful; the link is
35
+ // gated exactly as today.
36
+ // 'half-wired' — LB mode, the editor backend service is ABSENT (never
37
+ // created). The permanent 503 case — the public URL will fail
38
+ // until re-wired. The state that had NO signal before.
39
+ // 'iap-access-missing' — LB mode, the EDITOR backend service is IAP-enabled but
40
+ // has NO accessor binding, so IAP authorizes nobody: the public
41
+ // URL returns "Error code 51" for everyone even when the backend
42
+ // is healthy. Distinct from 'half-wired' (there the backend is
43
+ // absent; here it exists and serves, but nobody's granted) — the
44
+ // exact "wired + IAP-enabled but 51s in the browser" gap.
45
+ // 'preview-iap-access-missing' — LB mode, the editor surface grants access but
46
+ // the dedicated PREVIEW backend has no accessor binding. The
47
+ // editor URL works, but the framed Live Preview (served from the
48
+ // separate preview host on client-routed stacks) is blank for
49
+ // every authorized user because nobody is granted THERE. A
50
+ // softer, non-URL-blocking warning than 'iap-access-missing'
51
+ // (the editor link is live) — the monitoring blind spot the
52
+ // editor-only IAP probe used to hide.
53
+ // 'starting' — LB mode, the editor backend exists but is UNHEALTHY (LB has
54
+ // not yet marked it HEALTHY). The transient 503 case; flips to
55
+ // 'ready' on its own once the health check passes.
56
+ // 'broker-down' — reachable + LB-healthy (or legacy mode), but the broker
57
+ // daemon is down, so the VM cannot start an agent session.
58
+ // 'ready' — reachable, broker up, and (LB mode) the editor backend is
59
+ // HEALTHY. The only state whose editor link is live + green.
60
+ //
61
+ // Legacy mode (FLEET_DOMAIN unset ⇒ ctx.lbMode falsey, or no backendHealth
62
+ // supplied): there is no LB to health-check, so the backend-health branch is
63
+ // skipped and readiness degrades to today's reachability/broker behavior — no
64
+ // regression, no gcloud calls.
65
+ // 'ws-dead' — reachable + LB-healthy, but the public WebSocket path is
66
+ // dead: the LB either refused the upgrade outright
67
+ // (publicWsHealth 'unwired') or the backend-service timeout is
68
+ // below the fleet minimum so the LB severs the socket on a
69
+ // fixed interval (the VM5 guillotine). The editor answers HTTP
70
+ // but the Build tab can't hold a terminal — NOT 'ready'.
71
+ // 'cli-missing' — the editor is up but the build-agent CLI isn't installed
72
+ // (`providerAuth.cliInstalled === false`): a provisioning
73
+ // fault, not a usable VM.
74
+ // 'auth-unsettled' — the auth probe is wedged at `unknown` (timing out — the
75
+ // exact VM5 auth shape): we can't even tell if login is
76
+ // needed. A provisioning fault distinct from a clean
77
+ // needs-login.
78
+ // 'needs-login' — CLI installed and auth SETTLED to `needs-login`: a
79
+ // legitimate, expected state (the user must authenticate),
80
+ // surfaced with the Authenticate affordance — NOT a fault and
81
+ // NOT a provisioning fault that holds rostering.
82
+ const READINESS_STATES = [
83
+ 'provisioning',
84
+ 'unreachable',
85
+ 'half-wired',
86
+ 'iap-access-missing',
87
+ 'preview-iap-access-missing',
88
+ 'starting',
89
+ 'broker-down',
90
+ 'ws-dead',
91
+ 'cli-missing',
92
+ 'auth-unsettled',
93
+ 'needs-login',
94
+ 'ready',
95
+ ];
96
+
97
+ // The WS-timeout floor classifier lives next to classifyPublicUrlHealth (the
98
+ // public-URL probe is one concern); readiness consumes its verdict.
99
+ const fpub = require('./fleet-public-url');
100
+
101
+ // Classify the build-agent usability axis from the editor's own `providerAuth`
102
+ // subtree (vm: Readiness Verifies Real Usability). control-api emits the SAME
103
+ // `providerAuth` (with `cliInstalled` + settled `state`) on BOTH /api/config and
104
+ // /api/session-info via build_provider_auth_json, so the dashboard reads it
105
+ // straight off the gathered VM record — no re-derivation. Returns one verdict:
106
+ // 'cli-missing' — `cliInstalled === false`: a provisioning fault.
107
+ // 'auth-unsettled' — CLI installed but `state === 'unknown'` (probe wedged /
108
+ // timing out — the VM5 shape): a provisioning fault.
109
+ // 'needs-login' — CLI installed, `state === 'needs-login'`: a LEGITIMATE,
110
+ // actionable state, not a fault.
111
+ // 'ready' — CLI installed, `state === 'authenticated'`: usable.
112
+ // 'unknown' — providerAuth absent (older editor / probe never landed),
113
+ // or any other shape: absence of evidence, never a fault.
114
+ // Only an EXPLICIT `cliInstalled === false` / `state === 'unknown'` is a fault;
115
+ // missing fields stay 'unknown' so an older editor never reads as broken.
116
+ function classifyAgentUsability(providerAuth) {
117
+ if (!providerAuth || typeof providerAuth !== 'object') return 'unknown';
118
+ if (providerAuth.cliInstalled === false) return 'cli-missing';
119
+ if (providerAuth.cliInstalled !== true) return 'unknown';
120
+ if (providerAuth.state === 'unknown') return 'auth-unsettled';
121
+ if (providerAuth.state === 'needs-login') return 'needs-login';
122
+ if (providerAuth.state === 'authenticated') return 'ready';
123
+ return 'unknown';
124
+ }
125
+
126
+ // Map the agent-usability verdict to the tri-state the rostering gate
127
+ // (fleet-cleanup's reapVerdict) consumes, mirroring `projectWired`'s
128
+ // true|false|null contract so a flaky probe can never strand a healthy VM:
129
+ // false — a PROVISIONING FAULT ('cli-missing' / 'auth-unsettled'): the box is
130
+ // not usable, so don't roster it as healthy (the VM5 false-green).
131
+ // true — usable enough to roster: 'ready', or a legitimate 'needs-login'
132
+ // (the user must auth, but that's an expected state, not a
133
+ // half-provisioned box that should be held back).
134
+ // null — 'unknown' (no providerAuth / probe couldn't decide): absence of
135
+ // evidence must NEVER block rostering, exactly like reapVerdict's null
136
+ // tolerance for projectWired.
137
+ function agentRosterReady(verdict) {
138
+ if (verdict === 'cli-missing' || verdict === 'auth-unsettled') return false;
139
+ if (verdict === 'ready' || verdict === 'needs-login') return true;
140
+ return null;
141
+ }
142
+
143
+ // A VM mid-provision: an Add (incl. its recovering re-run) that is queued
144
+ // (number reserved, cloud:up not started) or running. Its public URL has no
145
+ // meaningful readiness yet, so this takes precedence over every other signal —
146
+ // a freshly-reserved VM must not flash 'half-wired' before its backend exists.
147
+ function isProvisioning(job) {
148
+ if (!job) return false;
149
+ if (job.action === 'add' && (job.state === 'running' || job.state === 'queued')) return true;
150
+ if (job.recovering && job.state === 'running') return true;
151
+ return false;
152
+ }
153
+
154
+ // `v` is the merged poll state (state.vms[n]); `ctx` carries the derived values
155
+ // the card builder already has on hand:
156
+ // ctx.job — the active job (or null), for the provisioning check.
157
+ // ctx.brokerStatus — the debounced broker tri-state ('down'|'idle'|
158
+ // 'agent-live'|'unknown') the card already derives.
159
+ // ctx.lbMode — true when FLEET_DOMAIN is set (the LB fronts the URLs).
160
+ // When false, the backend-health branch is skipped.
161
+ function deriveVmReadiness(v, ctx) {
162
+ const c = ctx || {};
163
+ // 1. Still being built — don't judge a half-built VM as ready or half-wired.
164
+ if (isProvisioning(c.job)) return 'provisioning';
165
+ // 2. Editor unreachable over the tunnel: we polled nothing, so broker / LB
166
+ // health are unknowable. Link gated, exactly as the pre-readiness card did.
167
+ if (!v || !v.reachable) return 'unreachable';
168
+ // 3. LB mode only: the public URL's fate is the editor backend's LB health.
169
+ // Skipped entirely in legacy mode (no LB) or before the first probe lands
170
+ // (no backendHealth) — both fall through to the reachability/broker path.
171
+ const bh = v.backendHealth;
172
+ if (c.lbMode && bh) {
173
+ // Backend service never created ⇒ permanent 503 until re-wired.
174
+ if (bh.editor === 'absent') return 'half-wired';
175
+ // Backend exists but not yet HEALTHY ⇒ transient 503 during cold start.
176
+ if (bh.editor === 'unhealthy') return 'starting';
177
+ // 'healthy' falls through to ready; 'unknown' (get-health errored) is
178
+ // absence-of-evidence — never rendered as a problem, falls through too.
179
+ }
180
+ // 3b. IAP access: the backend is IAP-enabled but has NO accessor binding, so
181
+ // IAP authorizes nobody and the public URL returns "Error code 51" for
182
+ // everyone — even when the backend is healthy. A wiring-class fault a plain
183
+ // GET / backend-health probe are both blind to. Only an EXPLICIT
184
+ // 'no-accessor' verdict fires; absence/'unknown' is never a fault (an older
185
+ // dashboard that never gathered it must not read as broken). Gated on
186
+ // lbMode — legacy quick-tunnel mode has no per-BES IAP to check.
187
+ if (c.lbMode && v.iapAccess === 'no-accessor') return 'iap-access-missing';
188
+ // 4. Reachable but the broker daemon is down: the VM can't start a session.
189
+ if (c.brokerStatus === 'down') return 'broker-down';
190
+ // 5. WS-usability (LB mode): the editor answers HTTP and the backend is
191
+ // LB-healthy, but the public WebSocket path is dead — either the LB refused
192
+ // the upgrade outright (publicWsHealth 'unwired') or the backend-service
193
+ // timeout is below the fleet minimum so the LB severs the socket on a fixed
194
+ // interval (the VM5 guillotine). Either way the Build tab can't hold a
195
+ // terminal, so this is NOT 'ready'. Skipped in legacy mode (no LB). An
196
+ // 'unknown'/absent WS verdict is absence-of-evidence — never a fault.
197
+ if (c.lbMode) {
198
+ // Deterministic guillotine first: a BES timeout below the fleet minimum
199
+ // severs the socket on a fixed interval — trustworthy, no probe needed.
200
+ if (fpub.classifyWsTimeout(v.backendTimeoutSec) === 'ws-timeout-too-low') return 'ws-dead';
201
+ // The public WS probe is IAP-blind and false-positives `unwired` on a
202
+ // genuinely-healthy VM (VM-1, 2026-07-01: wired + HEALTHY + IAP-granted,
203
+ // yet `publicWsHealth: unwired` → phantom ws-dead → URL gated "not ready").
204
+ // Only trust an `unwired` verdict when the backend is NOT already HEALTHY —
205
+ // a HEALTHY backend with a valid/unknown timeout is never ws-dead.
206
+ if (v.publicWsHealth === 'unwired' && !(bh && bh.editor === 'healthy')) return 'ws-dead';
207
+ }
208
+ // 6. Agent usability: the box is up and the WS path carries frames, but the
209
+ // build agent itself may not be usable — CLI not installed, or the auth
210
+ // probe wedged at 'unknown'. A legitimate 'needs-login' surfaces as its own
211
+ // actionable state (Authenticate), distinct from a provisioning fault.
212
+ // Absence of providerAuth (older editor / unprobed) falls through to ready.
213
+ const usability = classifyAgentUsability(v.providerAuth);
214
+ if (usability === 'cli-missing') return 'cli-missing';
215
+ if (usability === 'auth-unsettled') return 'auth-unsettled';
216
+ if (usability === 'needs-login') return 'needs-login';
217
+ // 6b. Preview-host IAP gap: the editor surface grants access (the URL routes
218
+ // and the box is otherwise ready) but the dedicated preview backend has no
219
+ // accessor, so the framed Live Preview is blank for authorized users. A
220
+ // softer warning than the editor 'iap-access-missing' — it does NOT block
221
+ // the editor link (see URL_BLOCKED_READINESS) — so it is checked last, just
222
+ // before 'ready'. Only an EXPLICIT 'preview-no-accessor' verdict fires;
223
+ // absence/'unknown' is never a fault. Gated on lbMode like its editor twin.
224
+ if (c.lbMode && v.iapAccess === 'preview-no-accessor') return 'preview-iap-access-missing';
225
+ // 7. Reachable, broker up, WS carries frames, agent usable, and (LB mode) the
226
+ // editor backend is HEALTHY — genuinely ready: green, clickable, the public
227
+ // URL routes AND holds a WebSocket.
228
+ return 'ready';
229
+ }
230
+
231
+ module.exports = {
232
+ deriveVmReadiness,
233
+ READINESS_STATES,
234
+ isProvisioning,
235
+ classifyAgentUsability,
236
+ agentRosterReady,
237
+ };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-vm-slugs.js — the per-VM custom-slug sidecar
4
+ // (`.codeyam/logs/fleet-vm-slugs.json`). A slug is an optional human-readable
5
+ // override for a VM's leftmost hostname label, so its public URLs become
6
+ // `<slug>.editor.<domain>` instead of the number-derived `vm-<N>.editor.<domain>`.
7
+ //
8
+ // Why a sidecar and not the roster: the roster (`npm/fleet-roster.js`) is a flat
9
+ // integer array with a hard "sorted, de-duped set of integers" invariant that
10
+ // bash consumers (`vm-urls.sh`, the watchdog) depend on. Rather than break that
11
+ // shape, slugs live in a SEPARATE map `{ "<N>": "<slug>" }`, fronted by a
12
+ // read+write seam modeled exactly on fleet-roster.js (atomic temp-file + rename,
13
+ // tolerant of a missing/malformed file).
14
+ //
15
+ // The slug is purely a public-hostname override — internal GCP resource names
16
+ // (NEG / backend service / path matcher) stay keyed off the VM number, so a VM
17
+ // added without a slug keeps its `vm-<N>` URLs unchanged.
18
+ //
19
+ // Env override: `VM_SLUGS_FILE` relocates the file (mirrors fleet-roster.js's
20
+ // `VMS_STATE_FILE`); resolved once at module load.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ const { rootDir } = require('./utils');
26
+ const { normalizeVmNumber, normalizeVmSlug } = require('./vm-urls');
27
+
28
+ const VM_SLUGS_FILE = process.env.VM_SLUGS_FILE
29
+ || path.join(rootDir, '.codeyam', 'logs', 'fleet-vm-slugs.json');
30
+
31
+ // Read the slug sidecar, returning a `{ [vmNumber]: slug }` object, or null if
32
+ // the file is missing or malformed (treated as "no slugs"). Keys are kept as the
33
+ // string form JSON emits; callers look them up by `String(n)`.
34
+ function readSlugs() {
35
+ try {
36
+ const data = JSON.parse(fs.readFileSync(VM_SLUGS_FILE, 'utf8'));
37
+ if (data && typeof data === 'object' && !Array.isArray(data)) return data;
38
+ } catch { /* missing or malformed */ }
39
+ return null;
40
+ }
41
+
42
+ // Persist `map` as the sidecar, atomically: write a temp file then `rename` it
43
+ // over the target so a concurrent reader never observes a half-written file. The
44
+ // temp name carries the pid so two processes writing at once don't clobber each
45
+ // other's temp file before their renames land. Returns the written map.
46
+ function writeSlugs(map) {
47
+ const next = map && typeof map === 'object' && !Array.isArray(map) ? map : {};
48
+ fs.mkdirSync(path.dirname(VM_SLUGS_FILE), { recursive: true });
49
+ const tmp = `${VM_SLUGS_FILE}.${process.pid}.tmp`;
50
+ fs.writeFileSync(tmp, JSON.stringify(next));
51
+ fs.renameSync(tmp, VM_SLUGS_FILE); // atomic replace
52
+ return next;
53
+ }
54
+
55
+ /** The slug mapped to VM `n`, or null when it has none (or the file is absent).
56
+ * This is the resolver every hostname derivation funnels through:
57
+ * `slugFor(n) || ('vm-' + n)`. */
58
+ function slugFor(n) {
59
+ const num = normalizeVmNumber(n);
60
+ const map = readSlugs();
61
+ const slug = map && map[String(num)];
62
+ return typeof slug === 'string' && slug.length > 0 ? slug : null;
63
+ }
64
+
65
+ /** The VM number currently mapped to `slug`, or null when no VM owns it. Used to
66
+ * reject a duplicate slug BEFORE it is assigned (a slug must be unique across
67
+ * the fleet, else two VMs would claim the same hostname). `slug` is normalized
68
+ * first so the comparison is case-insensitive. */
69
+ function vmForSlug(slug) {
70
+ const want = normalizeVmSlug(slug);
71
+ const map = readSlugs() || {};
72
+ for (const [k, v] of Object.entries(map)) {
73
+ if (typeof v === 'string' && v.toLowerCase() === want) {
74
+ const num = Number(k);
75
+ if (Number.isInteger(num) && num > 0) return num;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /** Assign `slug` to VM `n`, validating the slug (via `normalizeVmSlug`) and
82
+ * rejecting a slug already mapped to a DIFFERENT VM. Returns the written map.
83
+ * Re-assigning a VM its own existing slug is an idempotent no-op-shaped write. */
84
+ function setSlug(n, slug) {
85
+ const num = normalizeVmNumber(n);
86
+ const norm = normalizeVmSlug(slug);
87
+ const owner = vmForSlug(norm);
88
+ if (owner != null && owner !== num) {
89
+ throw new Error(
90
+ `VM slug "${norm}" is already assigned to VM-${owner} — pick another name`,
91
+ );
92
+ }
93
+ const map = { ...(readSlugs() || {}) };
94
+ map[String(num)] = norm;
95
+ return writeSlugs(map);
96
+ }
97
+
98
+ /** Remove VM `n`'s slug mapping, returning the new map. Removing an absent entry
99
+ * is a no-op. Called on Destroy so a number freed and later re-used doesn't
100
+ * inherit a stale slug. */
101
+ function removeSlug(n) {
102
+ const num = normalizeVmNumber(n);
103
+ const map = { ...(readSlugs() || {}) };
104
+ delete map[String(num)];
105
+ return writeSlugs(map);
106
+ }
107
+
108
+ module.exports = {
109
+ VM_SLUGS_FILE,
110
+ readSlugs,
111
+ writeSlugs,
112
+ slugFor,
113
+ vmForSlug,
114
+ setSlug,
115
+ removeSlug,
116
+ };