@codeyam-editor/codeyam-editor 0.1.7 → 0.1.9

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 (45) hide show
  1. package/npm/cloud.js +477 -41
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +406 -25
  5. package/npm/fleet-branch.js +124 -1
  6. package/npm/fleet-build-data.js +12 -1
  7. package/npm/fleet-cleanup.js +84 -3
  8. package/npm/fleet-datastore-health.js +107 -0
  9. package/npm/fleet-memory.js +29 -4
  10. package/npm/fleet-queue.js +141 -0
  11. package/npm/fleet-revision.js +108 -0
  12. package/npm/fleet-run-setup.js +66 -0
  13. package/npm/fleet-session-activity.js +162 -0
  14. package/npm/fleet-vm-access.js +162 -0
  15. package/npm/fleet-vm-readiness.js +57 -16
  16. package/npm/ingress-invoke.js +46 -2
  17. package/npm/launch-config.js +169 -2
  18. package/npm/scenario-check.js +708 -35
  19. package/npm/scenario-handlers.js +18 -0
  20. package/npm/scenario-interactivity.js +477 -23
  21. package/npm/scenario-issues.js +72 -0
  22. package/npm/scenario-mocks.js +245 -39
  23. package/npm/scenario-playwright.js +137 -26
  24. package/npm/utils.js +262 -22
  25. package/package.json +5 -5
  26. package/ui/dist/assets/{ContainerInspector-Cu1tSN-o.js → ContainerInspector-C7I9wctz.js} +1 -1
  27. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  28. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  29. package/ui/dist/assets/{ProjectLauncherScreen-Dmm6Z0mv.js → ProjectLauncherScreen-E0NYYiwy.js} +1 -1
  30. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  31. package/ui/dist/assets/{ScenarioDataPanelFullPage-BqMxWVKW.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  32. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  33. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  34. package/ui/dist/assets/index-iMR5siiG.js +27 -0
  35. package/ui/dist/assets/useServerIdentity-CVuHTzjg.js +1 -0
  36. package/ui/dist/index.html +2 -2
  37. package/ui/dist/preview-subdir-demo/index.html +62 -0
  38. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  39. package/ui/dist/assets/EditorShell--V2ExYQ6.js +0 -260
  40. package/ui/dist/assets/EditorShell-DP5UY20G.css +0 -1
  41. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +0 -9
  42. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +0 -21
  43. package/ui/dist/assets/index-CjxWhkSy.css +0 -1
  44. package/ui/dist/assets/index-VuyqBbGJ.js +0 -27
  45. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +0 -1
@@ -97,8 +97,116 @@ function projectDashboardRevision(memo = {}) {
97
97
  return { ...memo, level: classifyDashboardStaleness(memo) };
98
98
  }
99
99
 
100
+ /** Is the CLI BINARY the dashboard shells out to as current as the checkout?
101
+ *
102
+ * The sibling question to `classifyDashboardStaleness`, and until 2026-08-21
103
+ * nobody was asking it. The operator host's checkout self-updates hourly while
104
+ * the `codeyam-editor` binary beside it never did, so the control plane drifted
105
+ * a little further behind every hour until a CLI-visible schema change broke it
106
+ * outright: an `editor-shipped` glossarySkip reason merged at 05:12 could not be
107
+ * parsed by a binary built 2026-07-19, and every CLI invocation the dashboard
108
+ * made died at config load.
109
+ *
110
+ * Why this needs its own signal rather than riding on `behind`: NOTHING that
111
+ * normally means "down" was down. `dashboard-bes` stayed HEALTHY, `/healthz`
112
+ * answered in 0.8 ms, all 14 VMs read reachable, and the checkout itself was at
113
+ * tip — so a commits-behind chip reading 0 was correct AND useless. Two
114
+ * artifacts deploy on this host and only one of them was ever measured.
115
+ *
116
+ * Compares git SHAs, not versions: the published artifact stamps the source
117
+ * commit it was built from (`org.opencontainers.image.revision`), and a semver
118
+ * moves far more slowly than the schema does — `0.1.7` was both the working and
119
+ * the broken binary.
120
+ *
121
+ * Levels, deliberately the same four words `classifyDashboardStaleness` uses so
122
+ * the two chips read as one family:
123
+ * 'stale' — both SHAs known and different. The CLI predates the checkout;
124
+ * a schema change away from empty panels.
125
+ * 'current' — both known and equal.
126
+ * 'unknown' — cannot compare AND something failed. Includes the incident's
127
+ * own worst case: a binary too stale to answer what it is fails
128
+ * the very read that would have proven it stale, so "I could not
129
+ * tell" must never round down to 'current'.
130
+ * 'unchecked' — cannot compare and nothing failed (the boot window before the
131
+ * first slow-cycle read). Renders nothing.
132
+ *
133
+ * Pure; no I/O. */
134
+ function classifyBinaryStaleness({ installed, tip, error } = {}) {
135
+ const sha = (v) => ((typeof v === 'string' && v.trim()) ? v.trim() : null);
136
+ const a = sha(installed);
137
+ const b = sha(tip);
138
+ if (a === null || b === null) return error ? 'unknown' : 'unchecked';
139
+ return a === b ? 'current' : 'stale';
140
+ }
141
+
142
+ /** The `/data` view of the binary memo, with the verdict resolved server-side.
143
+ *
144
+ * Same split, for the same reason, as `projectDashboardRevision`: `server.js`
145
+ * has top-level side effects on require, so a projection defined there can
146
+ * never be executed by a test and ships its bugs green.
147
+ *
148
+ * Pure; no I/O. */
149
+ function projectBinaryRevision(memo = {}) {
150
+ return { ...memo, level: classifyBinaryStaleness(memo) };
151
+ }
152
+
153
+ /** Why did the CLI shell-out fail — and is it the nameable one?
154
+ *
155
+ * The 2026-08-21 outage surfaced ONLY as a truncated `queueFreshness.error`
156
+ * reading `" Fix the file and re-run."` — two lines of tail from a multi-line
157
+ * diagnostic, with the sentence naming the cause among the lines that got cut.
158
+ * An operator looking at empty Queue and Plans panels had to infer that the CLI
159
+ * was broken at all, and nothing on the page said so.
160
+ *
161
+ * So classify the FULL stderr before it is tailed. Two phrasings both mean
162
+ * "this binary cannot read this checkout's config", and BOTH must match:
163
+ * the current wording, and the pre-2026-08-19 `Fix the file and re-run` — which
164
+ * is not legacy trivia but the single most likely text to see here, because the
165
+ * binary emitting it is by definition an old one.
166
+ *
167
+ * Levels:
168
+ * 'ok' — no error.
169
+ * 'config' — the binary cannot read `.codeyam/editor.json`. Almost always a
170
+ * CLI older than the checkout; the file is usually correct and
171
+ * editing it is the wrong move, which is why the recovery text
172
+ * says so out loud.
173
+ * 'failing' — the CLI failed for some other reason (git credentials, a
174
+ * timeout). Still worth showing, still not this condition.
175
+ *
176
+ * Pure; no I/O. */
177
+ function classifyCliFailure(text) {
178
+ const s = (typeof text === 'string') ? text : '';
179
+ if (!s.trim()) return 'ok';
180
+ if (/cannot read [^\n]*editor\.json/i.test(s) || /Fix the file and re-run/i.test(s)) return 'config';
181
+ return 'failing';
182
+ }
183
+
184
+ /** The `/data` view of CLI health, with the verdict and its recovery resolved
185
+ * server-side — so the panel renders a named condition instead of leaving the
186
+ * operator to infer one from a blank list.
187
+ *
188
+ * `recovery` is only populated for 'config', where the right action is
189
+ * genuinely non-obvious and the intuitive one (edit the file) is wrong.
190
+ *
191
+ * Pure; no I/O. */
192
+ function projectCliHealth({ error } = {}) {
193
+ const level = classifyCliFailure(error);
194
+ return {
195
+ level,
196
+ broken: level !== 'ok',
197
+ error: (typeof error === 'string' && error.trim()) ? error.trim() : null,
198
+ recovery: level === 'config'
199
+ ? 'The codeyam-editor binary is older than the checkout it is reading — do NOT edit .codeyam/editor.json. Refresh the binary: scripts/operator/refresh-operator-repo.sh'
200
+ : null,
201
+ };
202
+ }
203
+
100
204
  module.exports = {
101
205
  mergeDashboardRevision,
102
206
  classifyDashboardStaleness,
103
207
  projectDashboardRevision,
208
+ classifyBinaryStaleness,
209
+ projectBinaryRevision,
210
+ classifyCliFailure,
211
+ projectCliHealth,
104
212
  };
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+ // Pure builder for the fleet dashboard's in-container `npm run setup` snippet
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). String
4
+ // construction only — no I/O, no child processes — so the snippet's CONTENT is
5
+ // unit-testable in isolation (npm/fleet-run-setup.test.js), exactly like
6
+ // fleet-env.js. server.js itself has no unit-test harness, which is why this
7
+ // lives here rather than inline in actionRunSetup.
8
+ //
9
+ // Named for the run-setup ACTION, not "setup" generally: npm/fleet-setup-steps.js
10
+ // already exists and is a different concern (it is bound as `fsetup` in
11
+ // server.js, so a `fleet-setup.js` here collided on both the filename and the
12
+ // import identifier).
13
+
14
+ // Seconds before a prompting or hanging setup is killed. Matches the
15
+ // bootstrap's SETUP_TIMEOUT default so both paths time-box identically; the
16
+ // dashboard job budget (11 min) is deliberately larger, to cover this plus ssh.
17
+ const SETUP_TIMEOUT_SECS = 600;
18
+
19
+ // How many trailing lines of setup's own output ride on the CY_DEGRADED line.
20
+ // The marker is parsed line-by-line by the job-output reader, so these are
21
+ // flattened onto one line — keep it small enough to stay readable on a card.
22
+ const FAILURE_TAIL_LINES = 3;
23
+
24
+ /**
25
+ * Build the bash snippet that runs the project's `npm run setup` inside VM-n's
26
+ * container and reports what actually broke.
27
+ *
28
+ * The failure line carries the setup command's OWN trailing output. It used to
29
+ * hardcode "set env (e.g. DATABASE_URL) via Env, then retry Run setup" — a
30
+ * guess, and on VM-11 (2026-08-26) a wrong one: DATABASE_URL was present in
31
+ * /workspace/.env the whole time and the real failure was that Postgres was not
32
+ * installed. The failing script had already printed a precise, actionable
33
+ * message and the wrapper talked over it, sending the operator to configure env
34
+ * that was already correct. Report what broke; do not theorize about it.
35
+ *
36
+ * Exits 0 even on failure, emitting a `CY_DEGRADED` marker line instead — so
37
+ * the cause persists on the VM card rather than vanishing as a transient error.
38
+ *
39
+ * @param {number|string} n VM number, used only to name the VM in the message.
40
+ * @returns {string} bash snippet suitable for `inContainer(n, …)`.
41
+ */
42
+ function buildRunSetupScript(n) {
43
+ return [
44
+ 'cd /workspace || { echo "no /workspace in container"; exit 1; }',
45
+ `setup="$(node -p "(require('./package.json').scripts||{}).setup||''" 2>/dev/null || true)"`,
46
+ 'if [ -z "$setup" ]; then echo "no \'setup\' script in package.json — nothing to run"; exit 0; fi',
47
+ `echo "running 'npm run setup' (script: $setup; non-interactive, ${SETUP_TIMEOUT_SECS}s timeout)"`,
48
+ // tee so the full output still streams into the job log, while the tail is
49
+ // retained for the CY_DEGRADED line. ${PIPESTATUS[0]} is setup's exit code,
50
+ // not tee's — without it a failing setup would report success.
51
+ 'log="$(mktemp)"',
52
+ `timeout ${SETUP_TIMEOUT_SECS} npm run setup </dev/null 2>&1 | tee "$log"; rc=\${PIPESTATUS[0]}`,
53
+ 'if [ "$rc" -ne 0 ]; then',
54
+ ` why="$(grep -v '^[[:space:]]*$' "$log" | tail -n ${FAILURE_TAIL_LINES} | tr '\\n' '|' | sed 's/|$//; s/|/ | /g')"`,
55
+ ` [ -n "$why" ] || why="(no output — likely killed by the ${SETUP_TIMEOUT_SECS}s timeout)"`,
56
+ ` echo "CY_DEGRADED 'npm run setup' failed (exit $rc) on VM-${n}, in the container as root: $why"`,
57
+ 'fi',
58
+ 'rm -f "$log"',
59
+ ].join('\n');
60
+ }
61
+
62
+ module.exports = {
63
+ buildRunSetupScript,
64
+ SETUP_TIMEOUT_SECS,
65
+ FAILURE_TAIL_LINES,
66
+ };
@@ -0,0 +1,162 @@
1
+ 'use strict';
2
+ // Pure session-activity classification and stickiness for the Fleet Dashboard
3
+ // (scripts/operator/fleet-dashboard/server.js requires this). Given the raw
4
+ // per-poll `hasSession` / `sessionIdleSeconds` the editor's /api/session-info
5
+ // reports, decide what the card renders and what the maintenance gate reads. No
6
+ // I/O — server.js does the polling and threads the state.
7
+ //
8
+ // The problem this fixes (observed live 2026-08-30, VMs 1 and 9): `hasSession`
9
+ // is a bare boolean with no notion of activity age, so a session wedged two days
10
+ // ago is indistinguishable from one someone is actively driving. Both hold a
11
+ // live agent process, so both report `true`, and every refresh was refused with
12
+ // the same `BLOCKED: VM-N has a live session`. The only way through was
13
+ // `--force` — the same flag used to step over genuinely live work — which is how
14
+ // `--force` stops meaning anything. `hasSession` also flapped `false` → `true`
15
+ // between polls minutes apart, so even an operator watching the card could not
16
+ // tell the two apart by eye.
17
+ //
18
+ // Two independent fixes live here, deliberately separate:
19
+ //
20
+ // debounceHasSession — stops the flap. A value that alternates between
21
+ // adjacent polls is not a usable gate input at any
22
+ // age, so this is fixed first and on its own.
23
+ // classifySessionActivity — adds the age dimension the boolean lacks.
24
+ //
25
+ // Asymmetric by design, mirroring npm/fleet-broker-debounce.js: only the flip to
26
+ // `false` (no session) is debounced. `true` takes effect immediately, because
27
+ // the dangerous error is proceeding over live work — we never delay RAISING the
28
+ // block, only delay clearing it. An unreachable editor (`hasSession` neither
29
+ // true nor false) proves nothing and breaks a pending clear-streak rather than
30
+ // advancing it, the same "absence of evidence is never down" rule
31
+ // fleet-broker-status.js follows.
32
+ //
33
+ // State shape carried across polls: { hasSession, clearStreak, lastConfirmed }.
34
+ // hasSession — the debounced value the card and the gate read this poll.
35
+ // clearStreak — how many consecutive raw `false` polls we have seen.
36
+ // lastConfirmed — the last raw boolean we actually proved; held during a
37
+ // pending (sub-threshold) clear-streak so the gate keeps
38
+ // blocking through a blip.
39
+
40
+ // ~9s at the dashboard's 3s poll cadence, matching DOWN_DEBOUNCE_THRESHOLD in
41
+ // npm/fleet-broker-debounce.js. Named so retuning is a one-line edit.
42
+ const CLEAR_DEBOUNCE_THRESHOLD = 3;
43
+
44
+ // A full day of zero on-disk activity. Chosen to sit well clear of the longest
45
+ // legitimate idle a live session has: an agent parked at a confirmation gate
46
+ // overnight is waiting for its operator, not wedged, and forcing past it costs
47
+ // them their place. The observed wedge was two days cold, so this catches it
48
+ // with a wide margin rather than racing it.
49
+ const STALE_SESSION_IDLE_SECONDS = 24 * 60 * 60;
50
+
51
+ function debounceHasSession(prev, rawHasSession, opts) {
52
+ const threshold = (opts && opts.threshold) || CLEAR_DEBOUNCE_THRESHOLD;
53
+ const lastConfirmed = prev ? prev.lastConfirmed : undefined;
54
+
55
+ // Confirmed present: a session is live right now. Render immediately, reset
56
+ // the streak, and remember it as the last confirmed value.
57
+ if (rawHasSession === true) {
58
+ return { hasSession: true, clearStreak: 0, lastConfirmed: true };
59
+ }
60
+
61
+ // Neither true nor false — the editor was unreachable or answered no
62
+ // session-info at all, so we proved nothing. Never let that advance the
63
+ // clear-streak toward "no session"; hold what we last proved.
64
+ if (rawHasSession !== false) {
65
+ return {
66
+ hasSession: lastConfirmed === true,
67
+ clearStreak: 0,
68
+ lastConfirmed,
69
+ };
70
+ }
71
+
72
+ // Raw `false`: only believed once it persists. Count the streak.
73
+ const clearStreak = (prev ? prev.clearStreak : 0) + 1;
74
+ if (clearStreak >= threshold) {
75
+ return { hasSession: false, clearStreak, lastConfirmed: false };
76
+ }
77
+ // Pending (sub-threshold) clear: hold the last confirmed value, so a single
78
+ // dropped poll cannot open a window for maintenance to step over live work.
79
+ return {
80
+ hasSession: lastConfirmed === true,
81
+ clearStreak,
82
+ lastConfirmed,
83
+ };
84
+ }
85
+
86
+ // Classify a VM's session into the verdict the card renders and the maintenance
87
+ // gate branches on:
88
+ //
89
+ // 'none' — no session at all. Maintenance proceeds; nothing to step over.
90
+ // 'active' — a session with recent activity. Blocks exactly as it does
91
+ // today; only `--force` steps over it.
92
+ // 'stale' — a session idle beyond the threshold. Still blocks, but is
93
+ // eligible for the narrower stale-session override.
94
+ // 'unknown' — a session whose age could not be determined, because the editor
95
+ // is too old to report `sessionIdleSeconds` or could read no
96
+ // evidence. Treated as POSSIBLY LIVE and blocks like 'active'.
97
+ //
98
+ // That last mapping is the load-bearing one. An unknown age must never resolve
99
+ // toward 'stale': the override exists to skip work nobody is doing, and handing
100
+ // it out on absent evidence would step over live sessions on exactly the VMs
101
+ // whose reporting is broken. `classify_vm`'s `reachable` tri-state in
102
+ // scripts/operator/refresh-client-vm-editor.sh is the cautionary precedent —
103
+ // collapsing its unknown to a definite verdict once refused all 13 VMs at once
104
+ // on evidence that had never been gathered.
105
+ function classifySessionActivity(input) {
106
+ const hasSession = !!(input && input.hasSession);
107
+ if (!hasSession) return 'none';
108
+
109
+ const idleSeconds = input.idleSeconds;
110
+ if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds)) {
111
+ return 'unknown';
112
+ }
113
+
114
+ const threshold =
115
+ typeof input.thresholdSeconds === 'number' && Number.isFinite(input.thresholdSeconds)
116
+ ? input.thresholdSeconds
117
+ : STALE_SESSION_IDLE_SECONDS;
118
+
119
+ return idleSeconds >= threshold ? 'stale' : 'active';
120
+ }
121
+
122
+ // Render an idle age as the short human string the card and the refusal message
123
+ // both show ("2d", "5h", "3m", "12s"). Returns 'unknown' when there is no age,
124
+ // so a caller never has to decide how to word an absent value — and so the
125
+ // operator sees the word rather than a blank that reads as zero.
126
+ function formatIdleAge(idleSeconds) {
127
+ if (typeof idleSeconds !== 'number' || !Number.isFinite(idleSeconds) || idleSeconds < 0) {
128
+ return 'unknown';
129
+ }
130
+ const s = Math.floor(idleSeconds);
131
+ if (s < 60) return `${s}s`;
132
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
133
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
134
+ return `${Math.floor(s / 86400)}d`;
135
+ }
136
+
137
+ // Does this VM read as WEDGED rather than merely busy? A card showing
138
+ // `status: building` with no feature named and the workflow still at step 1 is
139
+ // describing a session that never got started, and when its last on-disk
140
+ // activity is also days old there is nothing left that "busy" explains. This is
141
+ // the combination VMs 1 and 9 presented, and the card rendered it as work in
142
+ // progress.
143
+ //
144
+ // Deliberately narrower than `classifySessionActivity(...) === 'stale'`: a
145
+ // long-running step that is genuinely mid-feature is stale-but-working, and
146
+ // labelling it wedged would train operators to ignore the label.
147
+ function isWedgedShape(input) {
148
+ if (!input) return false;
149
+ if (classifySessionActivity(input) !== 'stale') return false;
150
+ if (input.status !== 'building') return false;
151
+ if (input.feature) return false;
152
+ return input.step === 1 || input.step === undefined || input.step === null;
153
+ }
154
+
155
+ module.exports = {
156
+ debounceHasSession,
157
+ classifySessionActivity,
158
+ formatIdleAge,
159
+ isWedgedShape,
160
+ CLEAR_DEBOUNCE_THRESHOLD,
161
+ STALE_SESSION_IDLE_SECONDS,
162
+ };
@@ -215,6 +215,161 @@ function noDefaultOperatorDegradedReasons({ ok, action, lbMode, resolvedOperator
215
215
  ];
216
216
  }
217
217
 
218
+ /** The BLOCKED/Next-valid-action refusal for an Add that would create an
219
+ * IAP-gated VM authorizing NOBODY — the front-door counterpart to
220
+ * `noDefaultOperatorDegradedReasons`, which can only warn AFTER the VM exists.
221
+ * Fires only in LB mode (`lbMode` — a fleet domain is configured, so the VM's
222
+ * public URL is IAP-gated) on a numbered fleet VM (`vmNumber != null`) when the
223
+ * intended principal set is empty (`intendedCount === 0`): no `--vm-access`
224
+ * collaborators AND no default operator from any of the three tiers. The check
225
+ * depends only on env + config, never on GCP state, so the caller can run it in
226
+ * the add's preflight and refuse before an instance exists — provisioning a
227
+ * machine nobody can open, then revoking the slot's prior tenant, is what
228
+ * happened to VMs 4/5/6 on 2026-08-09.
229
+ *
230
+ * Names all THREE resolution tiers because which one to populate depends on the
231
+ * host: an automation-SA session (the dashboard) can only be fixed by env or
232
+ * cloud.json, while a human gcloud session resolves tier 3 on its own and never
233
+ * reaches this refusal. Returns null when the Add is fine (so the call site is a
234
+ * bare `if (msg) throw`), else the two-line recovery pair. Pure — the resolve
235
+ * happens at the call site and only its COUNT is passed in. */
236
+ function unauthorizableAddRefusal({ lbMode, vmNumber, intendedCount } = {}) {
237
+ if (!lbMode || vmNumber == null || intendedCount !== 0) return null;
238
+ return (
239
+ `BLOCKED: this Add would create an IAP-gated VM (VM-${vmNumber}) that authorizes NOBODY.\n`
240
+ + ` ${DEFAULT_OPERATOR_ENV} is unset, .codeyam/cloud.json has no \`vmAccessDefault\`,\n`
241
+ + ' and the active gcloud identity is a service account (skipped by design).\n'
242
+ + 'Next valid action: add `"vmAccessDefault": "user:you@example.com"` to\n'
243
+ + ' .codeyam/cloud.json (durable, no env re-bake), or pass --vm-access.'
244
+ );
245
+ }
246
+
247
+ /** Is this IAM principal a service account — i.e. authorizes no HUMAN? Matches
248
+ * both the typed form gcloud returns on a policy read (`serviceAccount:x@…`) and
249
+ * a bare `*.gserviceaccount.com` address, so the read-back below cannot be
250
+ * satisfied by the automation SA that `iap-access.sh` unions into every grant.
251
+ * Read-path semantics, like parseProjectMembers: it inspects whatever IAM
252
+ * already holds rather than validating a write, so it never throws. */
253
+ function isServiceAccountPrincipal(principal) {
254
+ const p = String(principal == null ? '' : principal).trim().toLowerCase();
255
+ if (p === '') return false;
256
+ return p.startsWith('serviceaccount:') || /gserviceaccount\.com$/.test(p);
257
+ }
258
+
259
+ /** Does a finished job warrant the post-add IAP read-back at all? Only a SUCCESSFUL
260
+ * `add` in LB mode has an IAP-gated public URL whose reachability is worth
261
+ * proving — a failed job, another action, or legacy quick-tunnel mode each have
262
+ * nothing to read back, and flagging them would bury a real failure under a wrong
263
+ * one. Split out so the caller can skip the (gcloud) policy read entirely on the
264
+ * cheap gates, and so that decision is stated in exactly one place instead of
265
+ * being duplicated at the call site and inside the verdict below.
266
+ *
267
+ * `wiringInFlight` is the fourth gate and the one that fixes the 2026-08-11 race:
268
+ * the IAP grant is the LAST step of the detached `wire-add-tail` that the add path
269
+ * spawns, and that tail runs for another 5–10 minutes after the Add settles at
270
+ * container-healthy. A read-back fired while the tail is alive is therefore racing
271
+ * the very step it checks for — it is GUARANTEED to observe zero accessor bindings
272
+ * on a perfectly healthy VM, which is exactly how healthy VM-4 and VM-7 were failed
273
+ * with "authorizes no human". A live tail means the grant provably has not run yet,
274
+ * so there is nothing to read back. Defaults false, so an omitted fact behaves
275
+ * exactly as before this input existed. */
276
+ function shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight } = {}) {
277
+ return !!ok && action === 'add' && !!lbMode && !wiringInFlight;
278
+ }
279
+
280
+ /** Is `job` still the job a post-add read-back was started for? The policy read is
281
+ * async, so a newer job can replace it mid-flight; downgrading THAT job would
282
+ * report a stale verdict against work it never describes. */
283
+ function readbackAppliesToJob(job) {
284
+ return !!job && job.action === 'add' && job.state === 'done';
285
+ }
286
+
287
+ /** Post-add read-back verdict: after a successful LB-mode Add, does VM-N's LIVE
288
+ * IAP policy authorize at least one non-service-account principal? This is the
289
+ * only thing that PROVES the end state is reachable — every other signal in the
290
+ * add path reports intent (what we meant to grant), and on 2026-08-09 intent and
291
+ * outcome disagreed: the job reported `done` while the URL authorized nobody.
292
+ *
293
+ * `iapMembers` is the union of accessor members across surfaces
294
+ * (`aggregateIapMembers(...).all`); `null` means a surface's read ERRORED, the
295
+ * same absence-of-evidence posture aggregateIapMembers itself takes.
296
+ *
297
+ * Reported as a discriminated outcome, so a caller can tell the four cases apart
298
+ * instead of collapsing three of them onto `null`:
299
+ *
300
+ * 'skipped' the check does not apply (not an ok LB-mode add, or the add's
301
+ * detached wiring is still in flight). Nothing to do.
302
+ * 'undetermined' a surface's policy read ERRORED (`iapMembers` is not an array).
303
+ * This is absence of evidence, NOT a pass: the caller must
304
+ * re-arm and re-check rather than concluding. Collapsing it onto
305
+ * "authorized" is how VM-5 settled `done` on 2026-08-11 with its
306
+ * access never actually proven — a flaky gcloud call under load
307
+ * silently skipped the only check that proves reachability.
308
+ * 'authorized' at least one non-service-account principal holds an accessor
309
+ * binding. The end state is provably reachable by a human.
310
+ * 'unauthorized' the policy was read and authorizes no human. `reason` carries
311
+ * the BLOCKED / `Next valid action:` remedy.
312
+ *
313
+ * `reason` is non-null only for 'unauthorized'. Pure — the gcloud read is the
314
+ * caller's. */
315
+ function iapReadbackOutcome({ ok, action, lbMode, wiringInFlight, iapMembers, vmNumber } = {}) {
316
+ if (!shouldReadBackIapAccess({ ok, action, lbMode, wiringInFlight })) {
317
+ return { outcome: 'skipped', reason: null };
318
+ }
319
+ if (!Array.isArray(iapMembers)) return { outcome: 'undetermined', reason: null };
320
+ if (iapMembers.some((p) => !isServiceAccountPrincipal(p))) return { outcome: 'authorized', reason: null };
321
+ const held = iapMembers.length > 0 ? iapMembers.join(', ') : '(none)';
322
+ return {
323
+ outcome: 'unauthorized',
324
+ reason: (
325
+ `BLOCKED: VM-${vmNumber} is up but its IAP policy authorizes no human — the public URL\n`
326
+ + ` returns "Error code 51" for everyone. Accessor bindings held: ${held}.\n`
327
+ + `Next valid action: configure a default operator (${DEFAULT_OPERATOR_ENV} or\n`
328
+ + ' .codeyam/cloud.json `vmAccessDefault`), then run\n'
329
+ + ` VM_ACCESS=user:you@example.com bash scripts/gcp/iap-access.sh ${vmNumber} grant`
330
+ ),
331
+ };
332
+ }
333
+
334
+ /** Timing policy for the DEFERRED post-add read-back: on this attempt, is there
335
+ * anything worth reading yet? Separate from shouldReadBackIapAccess, which asks
336
+ * whether the check applies AT ALL — this asks whether now is the moment.
337
+ *
338
+ * wiringInFlight the add's detached wiring tail is still running, and the IAP
339
+ * grant is its LAST step. Reading now can only observe the
340
+ * grant not having happened yet, so wait.
341
+ * wiringFailed the tail dropped its failed marker. A blown deadline is
342
+ * EVIDENCE, not a reason to keep waiting: read now and let a
343
+ * genuinely unauthorized VM escalate instead of waiting forever.
344
+ * budgetSpent the caller's total wait has elapsed. Read regardless — a
345
+ * crashed tail that never unlinked its marker must not defer the
346
+ * check indefinitely, and the classifier still renders the card
347
+ * calm while the wiring reads live.
348
+ *
349
+ * Returns 'defer' (re-arm and ask again later) or 'read' (do the policy read). */
350
+ function iapReadbackDeferral({ wiringInFlight, wiringFailed, budgetSpent } = {}) {
351
+ if (budgetSpent || wiringFailed) return 'read';
352
+ return wiringInFlight ? 'defer' : 'read';
353
+ }
354
+
355
+ /** What should the caller DO with an iapReadbackOutcome? Pure so the one rule that
356
+ * cost us a silently-skipped check is stated and tested in one place:
357
+ *
358
+ * An 'undetermined' read (a gcloud call that errored under load) must DEFER, never
359
+ * settle. Treating it as a pass is how VM-5 reached `done` on 2026-08-11 with its
360
+ * reachability never actually proven — the check that exists to catch an
361
+ * unreachable VM was skipped entirely by one flaky call. It settles only once the
362
+ * wait budget is spent, and even then it settles rather than failing: absence of
363
+ * evidence must not manufacture a failed Add either.
364
+ *
365
+ * Returns 'fail' (downgrade the job with the reason), 'defer' (re-arm and
366
+ * re-check), or 'settle' (leave the job alone). */
367
+ function iapReadbackNextAction({ outcome, budgetSpent } = {}) {
368
+ if (outcome === 'unauthorized') return 'fail';
369
+ if (outcome === 'undetermined') return budgetSpent ? 'settle' : 'defer';
370
+ return 'settle';
371
+ }
372
+
218
373
  /** Union the default-operator principal(s) into an access list, deduped and
219
374
  * order-preserving (explicit collaborators first, the operator appended if not
220
375
  * already present). Both inputs are normalized. The result is what gets GRANTED;
@@ -391,6 +546,13 @@ module.exports = {
391
546
  resolveDefaultOperator,
392
547
  defaultCloudConfigRead,
393
548
  noDefaultOperatorDegradedReasons,
549
+ unauthorizableAddRefusal,
550
+ isServiceAccountPrincipal,
551
+ shouldReadBackIapAccess,
552
+ readbackAppliesToJob,
553
+ iapReadbackOutcome,
554
+ iapReadbackDeferral,
555
+ iapReadbackNextAction,
394
556
  withDefaultOperator,
395
557
  parseProjectMembers,
396
558
  reconcileVmAccess,
@@ -71,6 +71,12 @@
71
71
  // 'cli-missing' — the editor is up but the build-agent CLI isn't installed
72
72
  // (`providerAuth.cliInstalled === false`): a provisioning
73
73
  // fault, not a usable VM.
74
+ // 'cli-broken' — the CLI IS installed but will not execute
75
+ // (`providerAuth.cliRunnable === false`): a provisioning
76
+ // fault whose remedy is repair/reinstall, not a fresh
77
+ // install and not re-authentication. Distinct from
78
+ // 'cli-missing' because sending an operator to "install it"
79
+ // when it is already installed wastes the incident.
74
80
  // 'auth-unsettled' — the auth probe is wedged at `unknown` (timing out — the
75
81
  // exact VM5 auth shape): we can't even tell if login is
76
82
  // needed. A provisioning fault distinct from a clean
@@ -89,6 +95,7 @@ const READINESS_STATES = [
89
95
  'broker-down',
90
96
  'ws-dead',
91
97
  'cli-missing',
98
+ 'cli-broken',
92
99
  'auth-unsettled',
93
100
  'needs-login',
94
101
  'ready',
@@ -100,23 +107,41 @@ const fpub = require('./fleet-public-url');
100
107
 
101
108
  // Classify the build-agent usability axis from the editor's own `providerAuth`
102
109
  // 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:
110
+ // `providerAuth` (with `cliInstalled` + `cliRunnable` + settled `state`) on BOTH
111
+ // /api/config and /api/session-info via build_provider_auth_json, so the
112
+ // dashboard reads it straight off the gathered VM record — no re-derivation.
113
+ // Returns one verdict:
106
114
  // '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,
115
+ // 'cli-broken' — CLI installed but `cliRunnable === false`: it resolves
116
+ // and will not execute. A provisioning fault.
117
+ // 'auth-unsettled' — CLI installed and runnable (or unprobed) but
118
+ // `state === 'unknown'` (probe wedged / timing out — the
119
+ // VM5 shape): a provisioning fault.
120
+ // 'needs-login' — CLI usable, `state === 'needs-login'`: a LEGITIMATE,
110
121
  // actionable state, not a fault.
111
- // 'ready' — CLI installed, `state === 'authenticated'`: usable.
122
+ // 'ready' — CLI usable, `state === 'authenticated'`: usable.
112
123
  // 'unknown' — providerAuth absent (older editor / probe never landed),
113
124
  // 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.
125
+ // Only an EXPLICIT `cliInstalled === false` / `cliRunnable === false` /
126
+ // `state === 'unknown'` is a fault; missing fields stay 'unknown' so an older
127
+ // editor never reads as broken.
128
+ //
129
+ // The `cli-broken` check sits BEFORE the state checks deliberately, and that
130
+ // ordering IS the fix. A CLI that will not execute cannot have authenticated,
131
+ // so `state: 'authenticated'` alongside `cliRunnable: false` is not a
132
+ // contradiction to be resolved in the state's favour — it is precisely the
133
+ // false green observed on VM-2 and VM-7 through the whole 2026-08-19/20 npm
134
+ // publish race, where the card read `cliInstalled: true, state: "authenticated"`
135
+ // while both boxes were unusable and the breakage was found only by ssh.
136
+ //
137
+ // `cliRunnable` is deliberately NOT required to be `true` to proceed: an older
138
+ // editor server omits it entirely (`undefined`), and treating that as a fault
139
+ // would mark every not-yet-upgraded VM in the fleet broken.
116
140
  function classifyAgentUsability(providerAuth) {
117
141
  if (!providerAuth || typeof providerAuth !== 'object') return 'unknown';
118
142
  if (providerAuth.cliInstalled === false) return 'cli-missing';
119
143
  if (providerAuth.cliInstalled !== true) return 'unknown';
144
+ if (providerAuth.cliRunnable === false) return 'cli-broken';
120
145
  if (providerAuth.state === 'unknown') return 'auth-unsettled';
121
146
  if (providerAuth.state === 'needs-login') return 'needs-login';
122
147
  if (providerAuth.state === 'authenticated') return 'ready';
@@ -126,8 +151,13 @@ function classifyAgentUsability(providerAuth) {
126
151
  // Map the agent-usability verdict to the tri-state the rostering gate
127
152
  // (fleet-cleanup's reapVerdict) consumes, mirroring `projectWired`'s
128
153
  // 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).
154
+ // false — a PROVISIONING FAULT ('cli-missing' / 'cli-broken' /
155
+ // 'auth-unsettled'): the box is not usable, so don't roster it as
156
+ // healthy (the VM5 false-green). 'cli-broken' joins the fault set
157
+ // because a CLI that will not execute makes the VM exactly as
158
+ // unusable as one that was never installed — the two differ only in
159
+ // the remedy an operator should reach for, not in whether the box can
160
+ // do work.
131
161
  // true — usable enough to roster: 'ready', or a legitimate 'needs-login'
132
162
  // (the user must auth, but that's an expected state, not a
133
163
  // half-provisioned box that should be held back).
@@ -135,7 +165,13 @@ function classifyAgentUsability(providerAuth) {
135
165
  // evidence must NEVER block rostering, exactly like reapVerdict's null
136
166
  // tolerance for projectWired.
137
167
  function agentRosterReady(verdict) {
138
- if (verdict === 'cli-missing' || verdict === 'auth-unsettled') return false;
168
+ if (
169
+ verdict === 'cli-missing' ||
170
+ verdict === 'cli-broken' ||
171
+ verdict === 'auth-unsettled'
172
+ ) {
173
+ return false;
174
+ }
139
175
  if (verdict === 'ready' || verdict === 'needs-login') return true;
140
176
  return null;
141
177
  }
@@ -206,12 +242,17 @@ function deriveVmReadiness(v, ctx) {
206
242
  if (v.publicWsHealth === 'unwired' && !(bh && bh.editor === 'healthy')) return 'ws-dead';
207
243
  }
208
244
  // 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.
245
+ // build agent itself may not be usable — CLI not installed, CLI installed
246
+ // but not executable, or the auth probe wedged at 'unknown'. A legitimate
247
+ // 'needs-login' surfaces as its own actionable state (Authenticate),
248
+ // distinct from a provisioning fault. Absence of providerAuth (older
249
+ // editor / unprobed) falls through to ready.
213
250
  const usability = classifyAgentUsability(v.providerAuth);
214
251
  if (usability === 'cli-missing') return 'cli-missing';
252
+ // Surfaced as its own readiness state rather than folded into 'cli-missing':
253
+ // the card's whole job here is to send an operator to the right remedy, and
254
+ // "reinstall the broken CLI" is not "install the missing one".
255
+ if (usability === 'cli-broken') return 'cli-broken';
215
256
  if (usability === 'auth-unsettled') return 'auth-unsettled';
216
257
  if (usability === 'needs-login') return 'needs-login';
217
258
  // 6b. Preview-host IAP gap: the editor surface grants access (the URL routes