@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
@@ -25,6 +25,14 @@ const MARKER_PUBLISH_SKIPPED = 'CY_BINARY_PUBLISH_SKIPPED'; // pacer will NOT pu
25
25
  const MARKER_PATH_PULL = 'CY_PROVISION_PATH pull'; // this VM pulled a prebuilt image (~2 min fast path)
26
26
  const MARKER_PATH_COMPILE = 'CY_PROVISION_PATH compile'; // this VM compiled from source (~10 min slow path)
27
27
 
28
+ // Markers scripts/cloud-refresh-binary.sh emits into the between-session
29
+ // refresh log (/tmp/dash-refresh-binary-vm-<n>.log, or the systemd journal when
30
+ // the timer runs it unattended). Same stable-contract rule as the four above.
31
+ const MARKER_REFRESHED = 'CY_BINARY_REFRESHED'; // pulled + swapped a newer published binary, container restarted
32
+ const MARKER_REFRESH_CURRENT = 'CY_BINARY_CURRENT'; // already on the newest published binary; nothing to do
33
+ const MARKER_REFRESH_DEFERRED = 'CY_BINARY_REFRESH_DEFERRED'; // newer binary exists but a session is live (or state unknown)
34
+ const MARKER_REFRESH_DEGRADED = 'CY_DEGRADED binary-refresh'; // the refresh could NOT complete — this VM may be running stale
35
+
28
36
  // Mirror scripts/gcp-bootstrap.sh's BRANCH_SLUG: '/' → '-', lowercased, capped
29
37
  // at 128 chars. The dashboard and the VM MUST agree on the slug or the probe
30
38
  // checks different tags than the bootstrap pulls. Empty / missing branch → the
@@ -53,7 +61,7 @@ function usableImageTags({ registry, branch } = {}) {
53
61
  return tags;
54
62
  }
55
63
 
56
- // The core verdict: should a batch add pace one VM ahead of the rest?
64
+ // The core verdict: should a cold add pace one VM to compile+publish first?
57
65
  //
58
66
  // count how many VMs the operator asked to add
59
67
  // hasUsableImage the probe result — true (image present), false (definitely
@@ -61,24 +69,46 @@ function usableImageTags({ registry, branch } = {}) {
61
69
  // hasPushToken a write:packages token is present, so the pacer CAN publish
62
70
  //
63
71
  // Pace ONLY when it strictly pays AND can work:
64
- // - count > 1 — a single add is never serialized (never slower)
65
- // - hasUsableImage === false — known-absent; an existing image means all N can
66
- // pull concurrently, and `null` (unknown) falls
67
- // back to today's fan-out rather than risk a
68
- // 10-min serialization that buys nothing
72
+ // - count >= 1 — a batch (k>1) serializes ONE ahead so the rest pull;
73
+ // a single cold add (k==1) still paces so it PUBLISHES
74
+ // the branch image, making every FUTURE add (single or
75
+ // batch) pull instead of source-building. Gating the
76
+ // publish behind k>1 was a bug: adding VMs one at a time
77
+ // bypassed the pacer entirely, so no image was ever
78
+ // produced and every single add recompiled.
79
+ // - hasUsableImage !== true — a usable image lets all N pull concurrently, so
80
+ // only pace when one is NOT known to exist. Both
81
+ // `false` (known-absent) AND `null` (undetermined)
82
+ // pace: publishing is cheap + idempotent, and pacing
83
+ // is the ONLY path that bootstraps self-feeding.
84
+ // Fanning out on `null` is exactly what permanently
85
+ // wedges a docker-less operator host whose probe can
86
+ // never confirm absence (see
87
+ // npm/cloud.js::probeUsableEditorImage) — republishing
88
+ // an image that already existed is the harmless worst
89
+ // case.
69
90
  // - hasPushToken — without it the pacer can't publish, so pacing would
70
91
  // just serialize N source compiles for no gain
71
- // Any miss → fan out all N immediately (today's behavior, strictly additive).
92
+ // Fan out immediately only when a usable image is KNOWN to exist, or there is no
93
+ // push token (today's behavior for those two cases, strictly additive).
72
94
  function decideBatchPrewarm({ count, hasUsableImage, hasPushToken } = {}) {
73
95
  const k = Math.round(Number(count) || 0);
74
- if (k <= 1) return { pace: false, reason: 'single add — never serialized' };
96
+ if (k < 1) return { pace: false, reason: 'no VMs requested' };
75
97
  if (hasUsableImage === true)
76
98
  return { pace: false, reason: 'usable prebuilt image exists — all pull concurrently' };
77
- if (hasUsableImage !== false)
78
- return { pace: false, reason: 'image presence undetermined — fan out (no worse than today)' };
79
99
  if (!hasPushToken)
80
100
  return { pace: false, reason: 'no push token — pacer could not publish, fan out' };
81
- return { pace: true, reason: `pacing 1 of ${k} — branch image absent, will publish for the rest` };
101
+ // hasUsableImage is `false` (known-absent) or `null` (undetermined); with a push
102
+ // token, pace either way to bootstrap self-feeding.
103
+ const basis = hasUsableImage === false
104
+ ? 'branch image absent'
105
+ : 'image presence undetermined — publishing to bootstrap self-feeding';
106
+ return {
107
+ pace: true,
108
+ reason: k > 1
109
+ ? `pacing 1 of ${k} — ${basis}, will publish for the rest`
110
+ : `single cold add — ${basis}, will compile and publish so future adds pull`,
111
+ };
82
112
  }
83
113
 
84
114
  // Has the pacer's add log settled to a definite publish outcome the held batch
@@ -149,11 +179,57 @@ function pushTokenDiagnostic({ hasPushToken } = {}) {
149
179
  };
150
180
  }
151
181
 
182
+ // What did the last between-session binary refresh do? Parsed from the refresh
183
+ // log for the VM card, so an operator can tell "up to date" from "could not
184
+ // update" without opening a log.
185
+ //
186
+ // Returns 'refreshed' | 'current' | 'deferred' | 'degraded' | null (no refresh
187
+ // has reported yet).
188
+ //
189
+ // The LAST marker wins, for the same reason provisionPath() takes the last one:
190
+ // a tick that defers and a later tick that refreshes must read as refreshed,
191
+ // not stay stuck on the older verdict. Ordering — not precedence between marker
192
+ // kinds — is what makes this correct across repeated timer runs.
193
+ //
194
+ // `degraded` is deliberately a distinct state from `deferred`. Deferring is the
195
+ // mechanism working as designed (a session is live, so we wait). Degraded means
196
+ // the VM wanted a newer binary and could NOT get one — the silent-staleness
197
+ // case this whole mechanism exists to make visible, and the only one that
198
+ // warrants an operator's attention.
199
+ function binaryRefreshState(logText) {
200
+ if (!logText || typeof logText !== 'string') return null;
201
+ const at = (marker) => logText.lastIndexOf(marker);
202
+ const candidates = [
203
+ ['refreshed', at(MARKER_REFRESHED)],
204
+ ['current', at(MARKER_REFRESH_CURRENT)],
205
+ ['deferred', at(MARKER_REFRESH_DEFERRED)],
206
+ ['degraded', at(MARKER_REFRESH_DEGRADED)],
207
+ ].filter(([, idx]) => idx >= 0);
208
+ if (!candidates.length) return null;
209
+ return candidates.reduce((best, cur) => (cur[1] > best[1] ? cur : best))[0];
210
+ }
211
+
212
+ // Operator-facing one-liner for a refresh state, for the VM card. null state →
213
+ // null (render nothing — a VM that has never run a refresh is not a problem).
214
+ function binaryRefreshLabel(state) {
215
+ if (state === 'refreshed') return 'editor binary refreshed to the newest published build';
216
+ if (state === 'current') return 'editor binary up to date';
217
+ if (state === 'deferred') return 'newer editor binary available — waiting for the session to end';
218
+ if (state === 'degraded') return 'could NOT refresh the editor binary — may be running stale';
219
+ return null;
220
+ }
221
+
152
222
  module.exports = {
153
223
  MARKER_PUBLISHED,
154
224
  MARKER_PUBLISH_SKIPPED,
155
225
  MARKER_PATH_PULL,
156
226
  MARKER_PATH_COMPILE,
227
+ MARKER_REFRESHED,
228
+ MARKER_REFRESH_CURRENT,
229
+ MARKER_REFRESH_DEFERRED,
230
+ MARKER_REFRESH_DEGRADED,
231
+ binaryRefreshState,
232
+ binaryRefreshLabel,
157
233
  branchSlug,
158
234
  usableImageTags,
159
235
  decideBatchPrewarm,
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+ // The fleet dashboard's two LB-path HTTP probe transports, extracted from
3
+ // scripts/operator/fleet-dashboard/server.js so their SOCKET LIFETIME is
4
+ // testable.
5
+ //
6
+ // The undici contract, which is the whole reason this module exists: a `fetch`
7
+ // response body is a stream that holds its connection open until it is read to
8
+ // completion or explicitly cancelled. IGNORING `res.body` is NOT the same as
9
+ // releasing it — the socket stays checked out of the agent's pool, and once the
10
+ // peer sends FIN it parks in CLOSE-WAIT forever. Node's `http` module is the
11
+ // opposite (an unread response pauses the socket, hence the `res.resume(); //
12
+ // drain so the socket can close` in server.js's localGetJson), and that
13
+ // asymmetry is what got missed when the fetch path was added.
14
+ //
15
+ // On 2026-08-01 that leaked 10,749 CLOSE-WAIT sockets to the fleet LB
16
+ // (8.228.227.92:443) holding 128MB of unread IAP-login HTML in kernel receive
17
+ // queues — ~460 sockets/hour, a ~1-day fuse — driving the dashboard cgroup into
18
+ // swap-thrash until the node event loop stalled in `D` state and dashboard-bes
19
+ // went UNHEALTHY while systemd still reported the unit active.
20
+ //
21
+ // So: EVERY return path here consumes or cancels the body. `fetch` is injected
22
+ // rather than closed over, so a test can drive every branch with a body that
23
+ // records whether it was cancelled.
24
+
25
+ const fpt = require('./fleet-vm-probe-target');
26
+
27
+ /** Release the connection behind a response we are not going to read.
28
+ *
29
+ * Swallows its own failure deliberately. `cancel()` rejects when the body was
30
+ * already consumed or already errored — in both cases the connection is
31
+ * released anyway, so there is nothing to recover from and nothing to report.
32
+ * Letting it throw would be actively harmful: these calls sit inside the
33
+ * transports' `try`, so a rejection would be caught by the transport's own
34
+ * catch and misclassify a perfectly good HTTP response as `unreachable`.
35
+ */
36
+ async function disposeBody(res) {
37
+ try {
38
+ await res.body?.cancel();
39
+ } catch {
40
+ // Already consumed, already errored, or no body at all — connection is
41
+ // released either way.
42
+ }
43
+ }
44
+
45
+ /** GET JSON from a resolved LB target.
46
+ *
47
+ * Uses redirect:'manual' so IAP's 302→accounts.google.com surfaces as a
48
+ * classifiable response instead of the fetch silently following it and handing
49
+ * us a login page that fails JSON parsing — which would read as "VM is down"
50
+ * when the real fault is our token. That 302 carries a full HTML login page,
51
+ * which is exactly why the non-ok return below must dispose of the body.
52
+ *
53
+ * `deps` injects the transport's collaborators for tests: `fetchImpl` (default
54
+ * global fetch) and `warn` (default console.warn).
55
+ */
56
+ async function lbGetJson(target, timeout, deps = {}) {
57
+ const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
58
+ const ctl = new AbortController();
59
+ const timer = setTimeout(() => ctl.abort(), timeout);
60
+ try {
61
+ const res = await fetchImpl(target.url, {
62
+ headers: target.headers,
63
+ redirect: 'manual',
64
+ signal: ctl.signal,
65
+ });
66
+ const outcome = fpt.classifyProbeResponse({
67
+ status: res.status,
68
+ location: res.headers.get('location'),
69
+ });
70
+ if (outcome !== 'ok') {
71
+ // 401 and 403 share one outcome but have opposite fixes (OAuth client vs
72
+ // IAM grant). Name which one we hit, so the next misconfig is diagnosable
73
+ // from the log instead of a live curl against the backend.
74
+ const why = fpt.describeAuthMisconfig(res.status);
75
+ if (why) warn(`probe ${target.url}: HTTP ${res.status} — ${why}`);
76
+ await disposeBody(res);
77
+ return { outcome, json: null, status: res.status };
78
+ }
79
+ // `status` rides on every return so a caller can NAME the HTTP status in a
80
+ // fault warning. Without it a reachable-but-unusable body was indiscernible
81
+ // from a dead VM in the log — see probeBodyFault.
82
+ //
83
+ // Both branches below are body-safe without an explicit cancel: `res.text()`
84
+ // reads the stream to completion, so the connection is already released by
85
+ // the time JSON.parse runs or throws.
86
+ try { return { outcome: 'ok', json: JSON.parse(await res.text()), status: res.status }; }
87
+ catch { return { outcome: 'unreachable', json: null, error: 'bad-json', status: res.status }; }
88
+ } catch (err) {
89
+ // No response to dispose of — the request never produced one.
90
+ return {
91
+ outcome: 'unreachable',
92
+ json: null,
93
+ error: err && err.name === 'AbortError' ? 'timeout' : 'error',
94
+ };
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ /** Reachability-only transport for the PUBLIC preview ingress over the IAP-LB
101
+ * seam.
102
+ *
103
+ * Unlike lbGetJson it does not PARSE a body — the preview root returns HTML, so
104
+ * routing it through lbGetJson would fail-close on the non-JSON parse. It must
105
+ * still DISPOSE of that body on every path; "does not parse" was historically
106
+ * written as "does not touch", and that is precisely what leaked.
107
+ *
108
+ * Classifies purely on the HTTP response:
109
+ * any non-auth HTTP response (200, or even 404/502 — the LB routed to a
110
+ * backend that answered) → true (ingress wired);
111
+ * auth-misconfigured (302→Google login / 401 / 403) → null (UNKNOWN, never
112
+ * false): the preview backend is a SEPARATE IAP backend
113
+ * service whose audience/accessor may differ from the
114
+ * editor backend the operator token is minted for, so an
115
+ * auth challenge here proves nothing about the URL wiring;
116
+ * transport error / timeout → false (nothing answered).
117
+ *
118
+ * Uses redirect:'manual' so an IAP login 302 surfaces as classifiable instead
119
+ * of fetch silently following it to a login page.
120
+ */
121
+ async function lbProbeReachable(target, timeout, deps = {}) {
122
+ const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
123
+ const ctl = new AbortController();
124
+ const timer = setTimeout(() => ctl.abort(), timeout);
125
+ try {
126
+ const res = await fetchImpl(target.url, {
127
+ headers: target.headers,
128
+ redirect: 'manual',
129
+ signal: ctl.signal,
130
+ });
131
+ const outcome = fpt.classifyProbeResponse({
132
+ status: res.status,
133
+ location: res.headers.get('location'),
134
+ });
135
+ if (outcome === 'auth-misconfigured') {
136
+ const why = fpt.describeAuthMisconfig(res.status);
137
+ if (why) warn(`preview ingress ${target.url}: HTTP ${res.status} — ${why}`);
138
+ await disposeBody(res);
139
+ return null;
140
+ }
141
+ await disposeBody(res);
142
+ return true;
143
+ } catch {
144
+ // No response to dispose of — the request never produced one.
145
+ return false;
146
+ } finally {
147
+ clearTimeout(timer);
148
+ }
149
+ }
150
+
151
+ module.exports = {
152
+ disposeBody,
153
+ lbGetJson,
154
+ lbProbeReachable,
155
+ };
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-project-drift.js — pure classifier for the fleet dashboard's
4
+ // "project-dir-drift" surface: does a VM's LIVE project identity conflict with
5
+ // the identity the operator asked for at Add time?
6
+ //
7
+ // The drift surface exists to catch a genuine misprovision — a VM whose
8
+ // /workspace checked out the WRONG repo, so its live project name no longer
9
+ // matches the expected one. But a brand-new GREENFIELD project (added with
10
+ // --new-project / newProjectName, no clientRepo) has no origin remote and, on a
11
+ // cloud VM, is always bind-mounted at /workspace. Before the editor seeds its
12
+ // projectTitle, the live identity falls back to the bare mount basename
13
+ // "workspace", which never equals the expected newProjectName — a PERMANENT
14
+ // FALSE drift that made every healthy greenfield add read amber and then get
15
+ // misclassified as "Add failed". This module suppresses drift for exactly that
16
+ // un-onboarded greenfield shape while leaving every true-positive intact.
17
+ //
18
+ // Pure like fleet-add-failure.js: server.js is the thin I/O shell that gathers
19
+ // the poll state and renders the returned reasons; the decision is unit-tested
20
+ // here without a live VM.
21
+
22
+ // The bare basename of the cloud-VM bind mount. Every fleet VM mounts the
23
+ // client project at /workspace (see server.js's in-container gather and the
24
+ // `-w /workspace` exec target), so an un-onboarded greenfield project with no
25
+ // origin resolves its live name to this literal string until the editor seeds a
26
+ // real projectTitle.
27
+ const MOUNT_BASENAME = 'workspace';
28
+
29
+ // Decide the project-dir-drift reasons for one VM.
30
+ //
31
+ // expectedName the identity the operator asked for: repoBasename(clientRepo)
32
+ // for a clone, else newProjectName for greenfield, else null
33
+ // liveName the VM's live clientProject.name (null when unreachable)
34
+ // liveRepo the VM's live clientProject.repo — null for a project with no
35
+ // origin remote (always the case for a fresh greenfield add)
36
+ // hasClientRepo whether the Add was configured with a clientRepo (a clone);
37
+ // false for a --new-project greenfield add
38
+ //
39
+ // Returns { drifted, reasons }. `reasons` is [] when not drifted so the caller
40
+ // can spread it into the card's reason list unconditionally.
41
+ function classifyProjectDrift({ expectedName, liveName, liveRepo, hasClientRepo } = {}) {
42
+ // Un-onboarded greenfield: no configured clientRepo, no live origin remote,
43
+ // and the live name is still the bare mount basename. This is a healthy fresh
44
+ // project that simply has not been wired to a remote yet — never real drift.
45
+ // Note the live-origin check: once a greenfield project DOES gain an origin
46
+ // whose basename conflicts with expected, `liveRepo` is non-null and this
47
+ // suppression no longer applies, so a genuine later misprovision still drifts.
48
+ const greenfieldUnonboarded =
49
+ !hasClientRepo && !liveRepo && liveName === MOUNT_BASENAME;
50
+ const drifted =
51
+ !greenfieldUnonboarded && !!(expectedName && liveName && expectedName !== liveName);
52
+ const reasons = drifted
53
+ ? [`project-dir-drift (expected: ${expectedName}, running: ${liveName})`]
54
+ : [];
55
+ return { drifted, reasons };
56
+ }
57
+
58
+ module.exports = { classifyProjectDrift, MOUNT_BASENAME };
@@ -24,6 +24,12 @@
24
24
  // as the scripts it backstops. A project that never joins the fleet never sets
25
25
  // FLEET_DOMAIN and never reaches this probe.
26
26
 
27
+ // The IAP members probe parses the same `value(bindings.members)` gcloud shape
28
+ // the per-VM access sidecar already reads, so it reuses that parser rather than
29
+ // growing a second one. fleet-vm-access requires only utils + vm-urls, so this
30
+ // edge introduces no cycle.
31
+ const { parseProjectMembers } = require('./fleet-vm-access');
32
+
27
33
  // The Google HTTPS-LB "no backend answered" body signature: GFE emits this when
28
34
  // a host rule routes nowhere (or to a backendless default). A single source so
29
35
  // the classifier and any future log line agree on the string.
@@ -59,6 +65,202 @@ function classifyPublicUrlHealth({ statusCode, body, error } = {}) {
59
65
  return 'unknown';
60
66
  }
61
67
 
68
+ /** Classify a public-URL WebSocket-UPGRADE probe result into the same ingress-
69
+ * wiring vocabulary classifyPublicUrlHealth uses (vm: Readiness Verifies Real
70
+ * Usability). The plain GET proves the host rule ROUTES, but says NOTHING about
71
+ * whether the ingress carries a WebSocket UPGRADE — the exact VM5-class
72
+ * blindness: the editor answered HTTP 200 (its `101` even succeeded) while the
73
+ * LB severed the socket every 30s, so the Build tab looped "Reconnecting…" on a
74
+ * VM every readiness signal called healthy. server.js gathers a `101`-asserting
75
+ * upgrade against `<publicUrl>/ws/health` (the auth-free liveness endpoint that
76
+ * completes the upgrade, sends one frame, and closes — no broker, no PTY) and
77
+ * hands the result here. Follows the same absence-of-evidence contract:
78
+ *
79
+ * 'unknown' — a network / TLS / timeout error (no statusCode), OR a 3xx
80
+ * auth/IAP login redirect: the probe couldn't reach a verdict.
81
+ * NEVER a fault, exactly like classifyPublicUrlHealth's 'unknown'
82
+ * — a transient blip, or an IAP that bounced an un-authenticated
83
+ * probe to a login page, must never yank a healthy VM out of the
84
+ * roster. (An IAP-fronted VM whose browser WS works fine returns a
85
+ * 302 to a probe that carries no IAP credential — proof of nothing
86
+ * about the editor, so it cannot be 'unwired'.)
87
+ * 'wired' — the upgrade completed (HTTP 101). The WS path carries frames.
88
+ * 'unwired' — the LB/editor answered PAST auth and REFUSED the upgrade (a
89
+ * non-101, non-3xx code): a host/LB rule that doesn't carry
90
+ * WebSocket at all (the OTHER WS failure mode, distinct from the
91
+ * timeout guillotine below). This is the only definitive fault,
92
+ * so 'ws-dead' fires on it but never on the ambiguous 'unknown'.
93
+ */
94
+ function classifyPublicWsHealth({ statusCode, error } = {}) {
95
+ if (error || statusCode == null) return 'unknown';
96
+ const code = Number(statusCode);
97
+ if (code === 101) return 'wired';
98
+ // A 3xx is an auth/IAP login redirect — the probe carries no IAP credential,
99
+ // so it can never complete the handshake. Absence of evidence, never a fault
100
+ // (mirrors classifyPublicUrlHealth's 3xx-is-'wired'/never-unwired treatment).
101
+ if (code >= 300 && code < 400) return 'unknown';
102
+ // A non-101, non-redirect response is the editor answering past auth and
103
+ // refusing the upgrade — the genuine WS-unwired signal.
104
+ return 'unwired';
105
+ }
106
+
107
+ /** The IAP role that actually authorizes opening a VM's public URL. A policy can
108
+ * carry any number of other bindings; only this one decides the verdict, and
109
+ * only its members are real IAP principals. */
110
+ const IAP_ACCESSOR_ROLE = 'roles/iap.httpsResourceAccessor';
111
+
112
+ /** Classify a gcloud IAP get-iam-policy result for a backend service. PURE —
113
+ * takes `{ error, stdout }` and returns `{ verdict, members }`.
114
+ *
115
+ * `verdict` is the unchanged three-value vocabulary the ingress rendering and
116
+ * fleet-vm-readiness already depend on:
117
+ * 'unknown' — the gcloud read itself errored (absence of evidence, never
118
+ * manufactured as a fault; mirrors the gather probes).
119
+ * 'granted' — at least one binding carries roles/iap.httpsResourceAccessor,
120
+ * so someone can actually open the VM's public URL.
121
+ * 'no-accessor' — the policy read cleanly but names no accessor role, i.e. the
122
+ * VM is wired but nobody is granted (the public URL 51s in the
123
+ * browser).
124
+ *
125
+ * `members` is the list of principals holding the accessor role — the fact the
126
+ * old bare-verdict shape was structurally incapable of reporting. Because
127
+ * `domain:codeyam.com` always holds the role, "is anyone granted?" was always
128
+ * true, so a specific collaborator's MISSING binding could never be detected;
129
+ * that blindness is what let the dashboard render a failed grant as access for
130
+ * hours (VM-10, 2026-07-30). `members` is `[]` on a read error and on
131
+ * 'no-accessor' — an empty list is never evidence of a gap by itself, the verdict
132
+ * carries that.
133
+ *
134
+ * Input is `--format=value(bindings.role,bindings.members)`: one line per
135
+ * binding, fields TAB-separated, members within a binding `;`-separated. Only
136
+ * the accessor binding's members are returned — a roles/owner binding does not
137
+ * grant IAP web access, so listing its members as IAP principals would be a
138
+ * different flavor of the same lie.
139
+ */
140
+ function classifyIapAccess({ error, stdout } = {}) {
141
+ if (error) return { verdict: 'unknown', members: [] };
142
+ const lines = String(stdout || '')
143
+ .split('\n')
144
+ .map((l) => l.trim())
145
+ .filter(Boolean);
146
+ const accessorLines = lines.filter((l) => l.split('\t')[0].trim() === IAP_ACCESSOR_ROLE);
147
+ if (accessorLines.length === 0) return { verdict: 'no-accessor', members: [] };
148
+ // Reuse the members parser written for exactly this gcloud output shape
149
+ // (`;`/newline split, trim, dedupe preserving first appearance, drop `deleted:`
150
+ // tombstones) rather than growing a second one that would drift from it.
151
+ const members = parseProjectMembers(
152
+ accessorLines.map((l) => l.split('\t').slice(1).join('\t')).join('\n'),
153
+ );
154
+ return { verdict: 'granted', members };
155
+ }
156
+
157
+ /** Aggregate the per-surface IAP verdicts (editor + preview) into a single fleet
158
+ * verdict. PURE — takes `{ editor, preview }`, each a `classifyIapAccess`
159
+ * result, and returns:
160
+ * 'unknown' — either surface's read errored (absence of evidence,
161
+ * never manufactured as a fault).
162
+ * 'no-accessor' — the EDITOR backend has no accessor: the URL the
163
+ * operator opens 51s for everyone. The primary gap
164
+ * (dominates a preview gap — the editor is unusable).
165
+ * 'preview-no-accessor' — the editor grants access but the PREVIEW backend has
166
+ * no accessor. The exact shape that hid this bug: the
167
+ * editor loads, but the framed Live Preview (served
168
+ * from the separate preview host) is blank for every
169
+ * authorized user because nobody is granted there.
170
+ * 'granted' — BOTH surfaces authorize someone.
171
+ *
172
+ * The preview verdict is optional: an older dashboard that only gathered the
173
+ * editor surface passes `preview === undefined`, and the result collapses to the
174
+ * single-surface classification (never a phantom preview gap).
175
+ *
176
+ * Accepts each surface as either a bare verdict string or a full
177
+ * `classifyIapAccess` result `{ verdict, members }`, and RETURNS A STRING either
178
+ * way. The string return is load-bearing, not laziness: `deriveVmReadiness`
179
+ * compares `v.iapAccess` against 'no-accessor' / 'preview-no-accessor' directly
180
+ * (npm/fleet-vm-readiness.js), and the card's readiness-badge CSS keys off those
181
+ * same values. Members are aggregated separately by `aggregateIapMembers` so the
182
+ * verdict contract stays byte-identical. */
183
+ function aggregateIapAccess({ editor, preview } = {}) {
184
+ // Unwrap the `{ verdict, members }` shape; a bare string passes through, and
185
+ // undefined/null stay undefined/null so the single-surface fallback below and
186
+ // the 'unknown' guard both keep working.
187
+ const v = (s) => (s && typeof s === 'object' ? s.verdict : s);
188
+ editor = v(editor);
189
+ preview = v(preview);
190
+ // Absence of evidence on either surface is never a fault.
191
+ if (editor === 'unknown' || preview === 'unknown') return 'unknown';
192
+ // The editor is the URL the operator opens; if IT lacks an accessor, that is
193
+ // the primary gap and dominates any preview verdict.
194
+ if (editor !== 'granted') return 'no-accessor';
195
+ // Editor granted. With no preview verdict gathered, fall back to the
196
+ // editor-only 'granted' (backward-compatible with single-surface dashboards).
197
+ if (preview === undefined || preview === null) return 'granted';
198
+ // Editor granted but the preview host authorizes nobody — the framed preview
199
+ // is blank for authorized users even though the editor works.
200
+ if (preview !== 'granted') return 'preview-no-accessor';
201
+ return 'granted';
202
+ }
203
+
204
+ /** Aggregate the per-surface IAP MEMBER lists into the card's display truth.
205
+ * PURE — takes the same `{ editor, preview }` map of `classifyIapAccess`
206
+ * results `aggregateIapAccess` consumes, and returns
207
+ * `{ editor, preview, all, partial }`:
208
+ *
209
+ * editor / preview — that surface's accessor members ([] when the surface
210
+ * reported no accessor, or was not gathered).
211
+ * all — the union across surfaces, first-appearance order. This
212
+ * is what the card renders as "who actually has access".
213
+ * partial — true when a principal holds the role on SOME but not all
214
+ * gathered surfaces. That is the editor-works /
215
+ * preview-blank shape, visible per-principal rather than
216
+ * only as the coarse 'preview-no-accessor' verdict.
217
+ *
218
+ * Returns `null` when either gathered surface errored ('unknown'): a partial
219
+ * read would render a live principal as missing, which is the same class of lie
220
+ * in the opposite direction. Absence of evidence is not a member list. */
221
+ function aggregateIapMembers({ editor, preview } = {}) {
222
+ const surfaces = [editor, preview].filter((s) => s !== undefined && s !== null);
223
+ if (surfaces.some((s) => (typeof s === 'object' ? s.verdict : s) === 'unknown')) return null;
224
+ const listOf = (s) => (s && typeof s === 'object' && Array.isArray(s.members) ? s.members : []);
225
+ const e = listOf(editor);
226
+ const p = listOf(preview);
227
+ const all = [];
228
+ for (const m of [...e, ...p]) if (!all.includes(m)) all.push(m);
229
+ // A principal is "partial" when it is absent from at least one surface that was
230
+ // actually gathered. With only one surface gathered nothing can be partial.
231
+ const gathered = [];
232
+ if (editor !== undefined && editor !== null) gathered.push(e);
233
+ if (preview !== undefined && preview !== null) gathered.push(p);
234
+ const partial = gathered.length > 1
235
+ && all.some((m) => gathered.some((list) => !list.includes(m)));
236
+ return { editor: e, preview: p, all, partial };
237
+ }
238
+
239
+ // The fleet minimum backend-service `timeoutSec` a WebSocket needs to survive
240
+ // behind the External HTTPS LB. The LB severs a backend connection at
241
+ // `timeoutSec` regardless of liveness, so the default 30s guillotines the Build
242
+ // tab's terminal every 30s (the VM5 symptom). scripts/gcp/vm-ingress.sh sets —
243
+ // and always converges existing BES to — 86400 (24h); readiness asserts the
244
+ // same floor so a BES that drifted below it reads `ws-dead` instead of healthy.
245
+ // A single source so the classifier and any future log line agree on the number.
246
+ const FLEET_WS_TIMEOUT_MIN_SEC = 86400;
247
+
248
+ /** Classify a backend-service `timeoutSec` against the fleet WS minimum. PURE —
249
+ * server.js reads the value via `gcloud backend-services describe` and hands
250
+ * the number in, so the threshold logic is unit-testable without gcloud.
251
+ * Absence of evidence is never a fault (mirrors classifyBackendHealth):
252
+ *
253
+ * 'unknown' — not a usable positive number (describe errored /
254
+ * empty): no verdict, never a problem state.
255
+ * 'ws-timeout-too-low' — a real value BELOW the minimum: the LB WILL sever
256
+ * the socket (the VM5 guillotine). The only fault.
257
+ * 'ok' — timeoutSec ≥ the fleet minimum: long enough for WS. */
258
+ function classifyWsTimeout(backendTimeoutSec, minSec = FLEET_WS_TIMEOUT_MIN_SEC) {
259
+ const t = Number(backendTimeoutSec);
260
+ if (!Number.isFinite(t) || t <= 0) return 'unknown';
261
+ return t >= Number(minSec) ? 'ok' : 'ws-timeout-too-low';
262
+ }
263
+
62
264
  /** The sticky `ingress-unwired` degraded-reason string — the single source for
63
265
  * both the dashboard card reason and any log line. Embeds the advertised URL
64
266
  * and the EXACT re-wire command an operator runs to fix it, so the amber card
@@ -152,9 +354,41 @@ function classifyBackendAttachment(err, stdout) {
152
354
  return String(stdout == null ? '' : stdout).trim() === '' ? 'backendless' : 'attached';
153
355
  }
154
356
 
357
+ /** Collapse a VM's raw wiring signals into the single URL-state vocabulary the
358
+ * card renders (vm--add-time-url-wiring-converges-reliably), so "the link is
359
+ * converging" is DISTINCT from "the link is dead" — the operator is never shown
360
+ * a "ready" card over an unwired URL, nor left to infer state from an absent
361
+ * link. PURE — the dashboard reads the live facts (the public-URL probe verdict,
362
+ * the inflight marker, the failed marker: all I/O at the edge) and passes them
363
+ * in. Precedence:
364
+ * - `publicUrlHealth === 'wired'` → 'wired'. A real 2xx/3xx wins over any
365
+ * stale marker; the URL genuinely serves.
366
+ * - else `inflight` → 'wiring'. The add-tail converge loop is still running;
367
+ * the card reads a calm "wiring public URL…", not a false failure.
368
+ * - else `failed` → 'wiring-failed'. The converge loop exhausted its deadline
369
+ * without reaching `wired` — a real attention reason with a re-run command,
370
+ * never a silent `partial`.
371
+ * - else `null`. No wiring signal (legacy quick-tunnel, or a transient probe
372
+ * miss with no markers) — the caller falls back to its existing
373
+ * unwired/unknown handling rather than inventing a state. */
374
+ function classifyUrlWiringState({ publicUrlHealth, inflight, failed } = {}) {
375
+ if (publicUrlHealth === 'wired') return 'wired';
376
+ if (inflight) return 'wiring';
377
+ if (failed) return 'wiring-failed';
378
+ return null;
379
+ }
380
+
155
381
  module.exports = {
156
382
  classifyPublicUrlHealth,
383
+ classifyPublicWsHealth,
384
+ classifyIapAccess,
385
+ aggregateIapAccess,
386
+ aggregateIapMembers,
387
+ IAP_ACCESSOR_ROLE,
388
+ classifyWsTimeout,
389
+ FLEET_WS_TIMEOUT_MIN_SEC,
157
390
  ingressUnwiredReason,
158
391
  shouldAutoRewireIngress,
159
392
  classifyBackendAttachment,
393
+ classifyUrlWiringState,
160
394
  };