@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
package/npm/editor-dev.js CHANGED
@@ -40,6 +40,8 @@ const {
40
40
  logLauncherTeardownSignal,
41
41
  readMergedEditorConfig,
42
42
  resolveLauncherPort,
43
+ readServerOutputLogTail,
44
+ formatLaunchFailureError,
43
45
  staleBinaryBanner,
44
46
  enforceFreshBinary,
45
47
  } = require("./utils");
@@ -447,9 +449,29 @@ if (require.main === module) {
447
449
  buildLauncherArtifacts();
448
450
  }
449
451
 
452
+ // Both helpers take the port to BIND plus the TARGET PROJECT dir — the
453
+ // latter is what lets them find the project's `server-state.json`, and so
454
+ // find a running server whose control port is not `SERVER_PORT` at all.
450
455
  const serverChild = shouldRestart
451
- ? await restartServer(SERVER_PORT)
452
- : await ensureServer(SERVER_PORT);
456
+ ? await restartServer(SERVER_PORT, projectDir)
457
+ : await ensureServer(SERVER_PORT, projectDir);
458
+
459
+ // Nothing below may claim the editor is up until something is actually
460
+ // listening on the port we just bound. `ensureServer` returns null only
461
+ // when it ATTACHED to a live server, and its spawn path already races the
462
+ // port — but a launcher that prints a URL it never bound turned a
463
+ // recoverable port conflict into a debugging session, so the banner is
464
+ // gated on an independent probe rather than on that internal contract.
465
+ if (!(await waitForPort(SERVER_PORT, 10000, 200))) {
466
+ console.error(
467
+ formatLaunchFailureError(
468
+ SERVER_PORT,
469
+ projectDir,
470
+ readServerOutputLogTail(projectDir),
471
+ ),
472
+ );
473
+ process.exit(1);
474
+ }
453
475
 
454
476
  // Re-read the (possibly just-initialized) project config to decide
455
477
  // whether to wait on a dev server at all. On a project where init
package/npm/editor.js CHANGED
@@ -27,6 +27,7 @@ const {
27
27
  readMergedEditorConfig,
28
28
  resolveLauncherPort,
29
29
  resolveWrappedAppPort,
30
+ resolveRunningControlPort,
30
31
  classifyPortHolder,
31
32
  fetchServerIdentity,
32
33
  formatForeignListenerError,
@@ -247,13 +248,22 @@ async function main() {
247
248
  // possible outcome of a port collision — only stop the listener if
248
249
  // the identity probe confirms it's this project's server.
249
250
  if (shouldRestart) {
250
- const holder = await classifyPortHolder(SERVER_PORT);
251
+ // The port to TEAR DOWN is not necessarily SERVER_PORT: under the Project
252
+ // Launcher or the desktop supervisor the running server took a dynamic
253
+ // control port and recorded it in `server-state.json`, while
254
+ // `proxy.controlPort` is only the static default. `resolveRunningControlPort`
255
+ // is the one resolver for that question (it mirrors `resolve_control_port`
256
+ // in `crates/codeyam-editor/src/commands/editor/control_port.rs`); gating
257
+ // teardown on the configured port alone is what let a stale server survive a
258
+ // restart. SERVER_PORT stays the port to BIND.
259
+ const runningPort = resolveRunningControlPort(process.cwd(), SERVER_PORT);
260
+ const holder = await classifyPortHolder(runningPort);
251
261
  if (holder.kind === "foreign") {
252
- console.error(formatForeignListenerError(SERVER_PORT));
262
+ console.error(formatForeignListenerError(runningPort));
253
263
  process.exit(1);
254
264
  }
255
265
  if (holder.kind === "cross-project") {
256
- const outcome = await resolveCrossProjectCollision(SERVER_PORT, holder, "restart");
266
+ const outcome = await resolveCrossProjectCollision(runningPort, holder, "restart");
257
267
  if (outcome.action !== "took-over") {
258
268
  // "abort" (non-TTY) or "declined": resolver already printed the message.
259
269
  process.exit(1);
@@ -261,8 +271,8 @@ async function main() {
261
271
  // took-over: stopServer freed the port; fall through to the fresh-boot path.
262
272
  }
263
273
  if (holder.kind === "same-project") {
264
- console.error(`Stopping existing server on port ${SERVER_PORT}...`);
265
- await stopServer(SERVER_PORT);
274
+ console.error(`Stopping existing server on port ${runningPort}...`);
275
+ await stopServer(runningPort);
266
276
  }
267
277
  // holder.kind === "free": nothing to stop, fall through to ensureServer.
268
278
  }
@@ -33,7 +33,11 @@ const { parseRosterMarkers } = require('./fleet-early-roster');
33
33
  //
34
34
  // 1. NOTHING WAS PROVISIONED. The classifier can prove no cloud resource was
35
35
  // created (`preflight-drift` aborts before the first gcloud call;
36
- // `instance-vanished` confirms the instance is gone). Retry starts clean.
36
+ // `instance-vanished` confirms the instance is gone; `capacity-stockout`
37
+ // pairs a GCE stockout signature with the `Failed to create instance` line
38
+ // that `ensureInstance` throws from exactly one place — the branch where the
39
+ // instance was not found AND the create call failed, so no instance, disk,
40
+ // NEG or backend service can exist). Retry starts clean.
37
41
  //
38
42
  // 2. THE REMAINING WORK IS RE-ENTRANT. A real VM exists, but every step the
39
43
  // retry would re-run is skip-on-exists or re-attaching, so the retry
@@ -50,6 +54,7 @@ const { parseRosterMarkers } = require('./fleet-early-roster');
50
54
  const ADD_FAILURE_RETRYABLE = new Set([
51
55
  'preflight-drift',
52
56
  'instance-vanished',
57
+ 'capacity-stockout',
53
58
  'project-bootstrap-timeout',
54
59
  ]);
55
60
 
@@ -99,10 +104,13 @@ function transcriptRostered(logTail) {
99
104
  // The categories that describe an add whose outcome is NOT yet proven bad — the
100
105
  // calm, in-flight side of the classifier. `converging` is a rostered box whose
101
106
  // ingress is still wiring; `verifying` is the residue: no failure signature
102
- // matched and nothing durable proves the VM is dead. Both render a calm card and
103
- // neither may use the word "failed". Consumed by holdConvergingBadge and by
104
- // deriveStatus (server.js) to pick the matching calm status.
105
- const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying']);
107
+ // matched and nothing durable proves the VM is dead; `access-pending` is a VM that
108
+ // is up and rostered but whose IAP policy has no accessors YET, because its
109
+ // detached wiring tail — which applies the grant last — is still running. All
110
+ // three render a calm card and none may use the word "failed". Consumed by
111
+ // holdConvergingBadge and by deriveStatus (server.js) to pick the matching calm
112
+ // status.
113
+ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying', 'access-pending']);
106
114
 
107
115
  // Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
108
116
  // positive proof of death, rather than merely an absence of proof of life?
@@ -157,6 +165,191 @@ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
157
165
  return null;
158
166
  }
159
167
 
168
+ // Lift the `Next valid action:` remedy out of a BLOCKED reason so a classified
169
+ // card's recovery line stays the SAME text the producing check already emitted,
170
+ // instead of a second copy of the same instructions that can silently drift from
171
+ // it. Returns '' when the text carries no remedy, in which case the caller's
172
+ // static copy stands on its own.
173
+ function nextValidActionRemedy(text) {
174
+ const lines = String(text || '').split('\n');
175
+ const i = lines.findIndex((l) => /^\s*Next valid action:/.test(l));
176
+ return i === -1 ? '' : lines.slice(i).join('\n').trim();
177
+ }
178
+
179
+ // Name the capacity GCE was short of, from whatever the stockout transcript
180
+ // happens to expose — for the capacity-stockout recovery line. GCE reports this
181
+ // inconsistently: the 2026-08-20 VM-11 transcript carried `vmType:` / `zone:`
182
+ // bullets under errorInfo.metadatas, while the 2026-08-18 VM-13 spelling
183
+ // (ZONE_RESOURCE_POOL_EXHAUSTED_WITH_DETAILS) carried neither. So every part is
184
+ // optional and the phrase degrades to a generic-but-true sentence rather than
185
+ // interpolating `undefined` into operator-facing copy. Extracted alongside
186
+ // nextValidActionRemedy (the same shape: a pure transcript reader that enriches
187
+ // one arm's recovery text) so the degradation is asserted directly.
188
+ //
189
+ // `zone:` is anchored with ^\s* so the neighbouring `zonesAvailable:` bullet —
190
+ // which lists the zones that DID have capacity — cannot be mistaken for the zone
191
+ // that failed. Never throws; returns the generic phrase for empty input.
192
+ function stockoutCapacityPhrase(logTail) {
193
+ const log = String(logTail || '');
194
+ const vmType = (log.match(/^\s*vmType:\s*(\S+)/m) || [])[1];
195
+ const zone = (log.match(/^\s*zone:\s*(\S+)/m) || [])[1];
196
+ return [vmType, 'capacity', zone ? `in ${zone}` : 'in the target zone']
197
+ .filter(Boolean)
198
+ .join(' ');
199
+ }
200
+
201
+ // The spellings git uses for "that branch is not on the remote", each capturing
202
+ // the ref name. The union is not defensive padding — the two bootstrap scripts
203
+ // reach the remote with DIFFERENT git commands, and git words the same condition
204
+ // differently for each:
205
+ // `git fetch <url> <branch>` (clone_editor_repo, scripts/gcp-bootstrap.sh)
206
+ // → fatal: couldn't find remote ref <ref>
207
+ // `git clone --branch <b>` (clone_project, scripts/gcp-project-bootstrap.sh)
208
+ // → fatal: Remote branch <b> not found in upstream origin
209
+ // Keying on only the first — the one the 2026-08-25 transcripts happened to
210
+ // carry — would miss every client-side clone failure. Same union-of-spellings
211
+ // lesson capacity-stockout learned from GCE.
212
+ //
213
+ // Deliberately NOT included: git's `warning: Could not find remote branch …`,
214
+ // which is a submodule-update warning that accompanies a SUCCEEDING command.
215
+ // Matching it here would let an add that failed for an unrelated reason claim
216
+ // this category — and this arm sits above the generic one, so a false positive
217
+ // would suppress the true cause rather than merely add noise.
218
+ const MISSING_REF_PATTERNS = [
219
+ /couldn't find remote ref (\S+)/i,
220
+ /Remote branch (\S+) not found in upstream/i,
221
+ ];
222
+
223
+ // Which branch died, and which config field carried it — read from the transcript.
224
+ //
225
+ // The field matters because clientBranch and editorBranch are configured in
226
+ // different places: an operator who corrects only one re-issues a request that
227
+ // fails identically. It is derived from the log prefix of the nearest preceding
228
+ // marker line, because the two bootstrap scripts brand their output distinctly
229
+ // (`[gcp-bootstrap]` builds the editor image from the editor branch;
230
+ // `[project-bootstrap]` clones the client repo at the client branch). When the
231
+ // transcript carries neither marker the field is left null and the copy names
232
+ // both fields rather than guessing — the same degrade-to-true-but-generic
233
+ // discipline stockoutCapacityPhrase uses. Never throws; returns
234
+ // { ref: null, field: null } for input with no missing-ref signature.
235
+ function missingBranchRefFacts(logTail) {
236
+ const lines = String(logTail || '').split('\n');
237
+ for (let i = 0; i < lines.length; i += 1) {
238
+ let ref = null;
239
+ for (const re of MISSING_REF_PATTERNS) {
240
+ const m = lines[i].match(re);
241
+ if (m) { ref = m[1]; break; }
242
+ }
243
+ if (!ref) continue;
244
+ let field = null;
245
+ for (let j = i; j >= 0; j -= 1) {
246
+ if (/\[project-bootstrap\]/.test(lines[j])) { field = 'clientBranch'; break; }
247
+ if (/\[gcp-bootstrap\]/.test(lines[j])) { field = 'editorBranch'; break; }
248
+ }
249
+ return { ref, field };
250
+ }
251
+ return { ref: null, field: null };
252
+ }
253
+
254
+ // Operator-facing recovery copy for the missing-branch-ref arm.
255
+ //
256
+ // Extracted from the arm for the same reason stockoutCapacityPhrase was — it is
257
+ // the one arm whose copy VARIES with what the transcript happened to expose, so
258
+ // pulling it out lets the wording be asserted directly instead of only through
259
+ // classifyAddFailure, and keeps the arm itself a two-line decision.
260
+ //
261
+ // Two properties are load-bearing and are what the tests pin:
262
+ // - It states the outcome FIRST and never borrows the generic arm's "a partial
263
+ // instance may exist — reconcile or destroy it" language. At this point git
264
+ // has aborted before any image build or container work, so that advice sends
265
+ // the operator hunting for a box that was never created — the inversion that
266
+ // cost the 2026-08-25 recovery its first hour.
267
+ // - The remedy is a DIFFERENT action, never "retry". Re-issuing the same
268
+ // config against a deleted ref fails identically forever, which is exactly
269
+ // why this category is kept out of ADD_FAILURE_RETRYABLE.
270
+ //
271
+ // `field` is optional: when the transcript carries no bootstrap marker the copy
272
+ // names both fields rather than guessing which one carried the dead ref.
273
+ // Pure; no I/O.
274
+ function missingBranchRefRecovery({ ref, field } = {}) {
275
+ const carried = field
276
+ ? `Its ${field} still points at it`
277
+ : 'Its clientBranch and editorBranch both need checking';
278
+ return (
279
+ `Branch '${ref}' is not on origin any more — it was almost certainly pruned when it merged. ` +
280
+ 'Nothing was provisioned: git aborted before any image build or container work, so there is no instance, ' +
281
+ `disk, NEG or backend service to clean up, and nothing to destroy. ${carried}, so retrying as-is will fail ` +
282
+ 'identically every time. Re-add on a branch that still exists (the current fleet branch is the usual answer).'
283
+ );
284
+ }
285
+
286
+ // Facts for the wedged-insert arm: a GCE instance insert that was ACCEPTED and
287
+ // then never finished. Returns { wedged, code, instance } — `code` is the GCE
288
+ // support code when the transcript carried one, so the recovery copy can hand the
289
+ // operator the exact string a Support case needs instead of making them dig it out
290
+ // of the transcript by hand.
291
+ //
292
+ // Two spellings reach here and both are proof, so the match is a UNION:
293
+ // A. The raw GCE prose, which is what the 2026-09-04 VM-8 transcript carried:
294
+ // `Internal error. Please try again or contact Google Support. (Code: '…')`
295
+ // PAIRED with evidence this run actually attempted a create. The pairing is
296
+ // what keeps the arm narrow — the same prose surfacing from some other
297
+ // gcloud call is not an insert that wedged, and must not claim to be one.
298
+ // B. ensureInstance's own fast-fail line (npm/cloud.js), which fires when a
299
+ // re-add finds the slot still parked in PROVISIONING behind an operation
300
+ // that never reached DONE. That path exists precisely so the transcript
301
+ // carries a signature instead of an uninformative 2-minute timeout, so this
302
+ // arm keys on it directly.
303
+ //
304
+ // Deliberately NOT keyed on elapsed time. A duration heuristic ("PROVISIONING for
305
+ // too long") would misfire on a legitimately slow create; an INTERNAL_ERROR on an
306
+ // accepted insert is provable evidence, which is the same signature-over-duration
307
+ // discipline the capacity-stockout arm uses.
308
+ const WEDGED_INSERT_PROSE = /Internal error\. Please try again or contact Google Support/i;
309
+ const WEDGED_INSERT_FASTFAIL = /stuck in PROVISIONING behind a wedged create operation/i;
310
+ const WEDGED_INSERT_CREATE_ATTEMPTED = /not found\s*—\s*creating|Failed to create instance|did not reach RUNNING/i;
311
+
312
+ function wedgedInsertFacts(logTail) {
313
+ const log = String(logTail || '');
314
+ const wedged = WEDGED_INSERT_FASTFAIL.test(log)
315
+ || (WEDGED_INSERT_PROSE.test(log) && WEDGED_INSERT_CREATE_ATTEMPTED.test(log));
316
+ if (!wedged) return { wedged: false, code: null, instance: null };
317
+ return {
318
+ wedged: true,
319
+ code: (log.match(/\(Code:\s*'(-?\d+)'\)/) || [])[1] || null,
320
+ instance: (log.match(/Checking instance\s+(\S+?)\.\.\./) || [])[1] || null,
321
+ };
322
+ }
323
+
324
+ // Operator-facing recovery copy for the wedged-insert arm, extracted for the same
325
+ // reason stockoutCapacityPhrase and missingBranchRefRecovery were: its wording
326
+ // varies with what the transcript exposed, so pulling it out lets the copy be
327
+ // asserted directly rather than only through classifyAddFailure.
328
+ //
329
+ // Three properties are load-bearing and are what the tests pin:
330
+ // - It never borrows the generic arm's "reconcile or destroy it" line. A delete
331
+ // against a wedged slot is ACCEPTED and then queues behind the wedged insert
332
+ // rather than clearing it, so that advice sends the operator to an action that
333
+ // provably does not work — the same inversion that cost the 2026-08-25
334
+ // missing-branch-ref recovery its first hour.
335
+ // - It says plainly that this is not the operator's fault and not their fix.
336
+ // The only thing that resolves it is GCE-side.
337
+ // - It names the Support code when one was captured, because that string is
338
+ // what a Google Cloud Support case is keyed on.
339
+ function wedgedInsertRecovery({ code, instance } = {}) {
340
+ const named = instance || 'the instance';
341
+ const cite = code ? ` citing operation error code ${code}` : '';
342
+ return (
343
+ `GCE accepted the create for ${named} and then never finished it: the insert stayed at status RUNNING, progress 0 ` +
344
+ 'and eventually attached an INTERNAL_ERROR without ever reaching DONE, leaving the instance in PROVISIONING and its ' +
345
+ 'boot disk in CREATING. Nothing on our side failed and there is nothing here to reconcile. Do NOT retry — the ' +
346
+ 'instance name is held by the wedged operation, so a re-add cannot succeed while it is outstanding. Do NOT try to ' +
347
+ 'destroy it either — a delete is accepted but queues behind the wedged insert instead of clearing it. This needs ' +
348
+ `GCE-side resolution: open a Google Cloud Support case${cite}. If you need the slot before Google releases it, add ` +
349
+ 'on a different VM number.'
350
+ );
351
+ }
352
+
160
353
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
161
354
  // can never drift from the category.
162
355
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -179,16 +372,28 @@ function buildFailureVerdict(category, headline, recovery, extra) {
179
372
  // instanceAlive does GCP still list the instance?
180
373
  // containerHealthy did the in-VM editor answer /api/health?
181
374
  // wiringDeadlineBlown did the add-time wire tail drop its failed marker?
375
+ // wiringInFlight is that tail still RUNNING? Only the IAP read-back arm
376
+ // reads it, to tell "no accessors yet" from "no accessors,
377
+ // ever" — the grant is the tail's last step.
182
378
  //
183
379
  // Returns { category, headline, recovery, retryable, files? }.
184
380
  //
185
381
  // Detection is ORDER-SENSITIVE. The provision shell echoes cloud:up's tail, so a
186
- // drift / instance-vanished / container-stuck transcript ALSO contains the
187
- // generic `cloud:up FAILED (exit N)` line. Each specific (and every retryable)
188
- // signature is therefore tested BEFORE the generic cloud-up-failed signature, or
189
- // a retryable drift would misclassify as a non-retryable cloud:up failure and the
190
- // operator would lose the safe one-click recovery.
191
- function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
382
+ // drift / instance-vanished / container-stuck / capacity-stockout /
383
+ // missing-branch-ref / wedged-insert transcript ALSO contains the generic `cloud:up FAILED
384
+ // (exit N)` line. Each specific (and every retryable) signature is therefore
385
+ // tested BEFORE the generic cloud-up-failed signature, or a retryable drift would
386
+ // misclassify as a non-retryable cloud:up failure and the operator would lose the
387
+ // safe one-click recovery. That is not a hypothetical: it is exactly the bug
388
+ // `capacity-stockout` (arm 6) was added to fix, and its placement immediately
389
+ // above cloud-up-failed is load-bearing. `missing-branch-ref` (arm 7) has the
390
+ // same property for the same reason — it also sits immediately above the generic
391
+ // arm, and its ordering is what stops a dead ref reporting as a half-provisioned
392
+ // instance. `wedged-insert` (arm 8) is the third: it sits directly above the
393
+ // generic arm so a create GCE accepted-and-abandoned cannot report as an ordinary
394
+ // provisioning failure whose advice is to destroy the box — a destroy that, on a
395
+ // wedged slot, queues behind the wedge instead of clearing it.
396
+ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown, wiringInFlight } = {}) {
192
397
  const log = String(logTail || '');
193
398
 
194
399
  // 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
@@ -255,40 +460,164 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
255
460
  'The container came up but the project was never scaffolded (no .codeyam/editor.json on the VM), so provisioning did not finish onboarding. Reconcile this VM to complete the scaffold, then re-add.');
256
461
  }
257
462
 
258
- // 6. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
259
- // vanished / stuck signatures above did not match. A partial instance may
260
- // exist, so not retryable.
463
+ // 6. capacity-stockout — GCE had no capacity for the machine type in the target
464
+ // zone, so `ensureInstance` (npm/cloud.js) threw from its create branch. This
465
+ // fired twice in three days — VM-13 / payper on 2026-08-18 and VM-11 /
466
+ // codeyam-cms on 2026-08-20 — and both times classified as the generic
467
+ // cloud-up-failed below, whose copy wrongly warned that a partial instance may
468
+ // exist and whose non-retryable verdict refused the one failure that needs
469
+ // nothing but a retry. Each incident cost manual state surgery on the operator
470
+ // host.
471
+ //
472
+ // Retryable on ground 1 (NOTHING WAS PROVISIONED), proven STRUCTURALLY rather
473
+ // than by trusting the error prose: the arm fires only on a stockout signature
474
+ // AND the `Failed to create instance` line, which ensureInstance throws from
475
+ // exactly one place — the branch where the instance was not found and the
476
+ // create call then failed. That conjunction is positive proof no cloud
477
+ // resource exists, the same standing preflight-drift and instance-vanished
478
+ // already have; both incidents were verified by hand to have left no instance,
479
+ // disk, NEG or backend service behind. The not-rostered guard is belt-and-
480
+ // braces: if a future transcript stockouts on one attempt and then creates a
481
+ // box, a roster marker means a real VM exists and this arm must not fire.
482
+ //
483
+ // The signature is a UNION because GCE spells this more than one way: the
484
+ // 2026-08-18 transcript carried `ZONE_RESOURCE_POOL_EXHAUSTED_WITH_DETAILS`
485
+ // with `reason: stockout`, while 2026-08-20 carried `reason:
486
+ // resource_availability` with `state:STOCKOUT, sub-state:STOCKOUT` and the
487
+ // prose below. Keying on only the first would have missed the second. The
488
+ // prose alternative uses `\s+` because the message wraps mid-sentence.
489
+ //
490
+ // Tested BEFORE cloud-up-failed because the provision shell echoes cloud:up's
491
+ // tail, so a stockout transcript ALSO carries `cloud:up FAILED (exit N)` —
492
+ // matching that first is precisely the bug being fixed here.
493
+ if (/ZONE_RESOURCE_POOL_EXHAUSTED|\bSTOCKOUT\b|reason:\s*resource_availability|does not have\s+enough resources available/i.test(log)
494
+ && /Failed to create instance/i.test(log)
495
+ && !transcriptRostered(log)) {
496
+ return buildFailureVerdict('capacity-stockout', 'No capacity in the zone',
497
+ `GCE had no ${stockoutCapacityPhrase(log)} at that moment, so the create call failed before anything existed — there is no instance, disk, NEG or backend service to clean up, and nothing to undo. Capacity is transient but its return is not scheduled: wait a few minutes, then Retry add. Do NOT destroy anything.`);
498
+ }
499
+
500
+ // 7. missing-branch-ref — the requested branch no longer exists on origin, so
501
+ // git aborted the clone/fetch. Observed live on 2026-08-25: VMs 2, 4, 5 and
502
+ // 7 each died on `fatal: couldn't find remote ref editor-improvements-75`,
503
+ // a branch pruned from origin earlier that day when it merged. This repo
504
+ // prunes on merge, so ANY stored feature-branch name expires silently —
505
+ // this is a recurring condition, not a one-off.
506
+ //
507
+ // NOT retryable, and deliberately NOT a member of ADD_FAILURE_RETRYABLE
508
+ // even though it satisfies ground 1 (nothing was provisioned). Membership
509
+ // means "re-issuing the SAME request can succeed"; re-issuing an add
510
+ // against a deleted ref fails identically forever. Admitting it would
511
+ // automate the guaranteed-fail loop the operator was stuck in by hand. The
512
+ // honest remedy is a DIFFERENT request — re-add on a live branch — which is
513
+ // what the recovery copy names.
514
+ //
515
+ // Tested BEFORE cloud-up-failed for the reason arm 6 documents: the
516
+ // provision shell echoes cloud:up's tail, so this transcript ALSO carries
517
+ // the generic `cloud:up FAILED (exit N)` line. Matching that first is the
518
+ // bug being fixed — it is what made the real incident report the OPPOSITE
519
+ // of the truth, warning that "a partial instance may exist — reconcile or
520
+ // destroy it" when nothing had been provisioned at all.
521
+ const missingRef = missingBranchRefFacts(log);
522
+ if (missingRef.ref) {
523
+ return buildFailureVerdict('missing-branch-ref', 'Branch no longer exists',
524
+ missingBranchRefRecovery(missingRef));
525
+ }
526
+
527
+ // 8. wedged-insert — GCE accepted the instance insert and then never finished
528
+ // it. Observed live on 2026-09-04 (VM-8): the insert was submitted at
529
+ // 14:29:50Z, sat at `status: RUNNING, progress: 0` for hours, and eventually
530
+ // attached `INTERNAL_ERROR` WITHOUT ever transitioning to DONE. The instance
531
+ // stayed in PROVISIONING and its boot disk in CREATING indefinitely, and a
532
+ // subsequent `instances delete` was accepted but queued at PENDING behind the
533
+ // wedged insert — so the slot could be neither created nor destroyed.
534
+ //
535
+ // It needs its own category because every neighbour misdescribes it: it is
536
+ // NOT capacity-stockout (GCE accepted the request rather than rejecting it),
537
+ // NOT cloud-up-failed (nothing in the provisioning script failed), and
538
+ // emphatically not the `verifying` fallback it actually landed on — whose
539
+ // copy told the operator the add "may still be converging" and to wait, about
540
+ // a VM that never would.
541
+ //
542
+ // NOT retryable, and deliberately not a member of ADD_FAILURE_RETRYABLE: a
543
+ // retry cannot succeed while the instance name is held by a wedged operation,
544
+ // so admitting it would automate a guaranteed-fail loop — the same reasoning
545
+ // missing-branch-ref documents. Not in CALM_ADD_CATEGORIES either: it is
546
+ // terminal, not calm, and there is nothing left to converge.
547
+ //
548
+ // Tested BEFORE cloud-up-failed for the reason arm 6 documents: the provision
549
+ // shell echoes cloud:up's tail into every transcript, so matching the generic
550
+ // line first is precisely the bug being fixed.
551
+ const wedgedInsert = wedgedInsertFacts(log);
552
+ if (wedgedInsert.wedged) {
553
+ return buildFailureVerdict('wedged-insert', 'Create wedged in Google Cloud',
554
+ wedgedInsertRecovery(wedgedInsert));
555
+ }
556
+
557
+ // 9. cloud-up-failed — a generic cloud:up failure, reached ONLY when the drift /
558
+ // vanished / stuck / stockout / missing-ref / wedged-insert signatures above
559
+ // did not match. A partial instance may exist, so not retryable.
261
560
  if (/cloud:up FAILED \(exit/i.test(log)) {
262
561
  return buildFailureVerdict('cloud-up-failed', 'Provisioning failed',
263
562
  'cloud:up failed during provisioning, so a partial instance may exist. Reconcile or destroy this VM before re-adding.');
264
563
  }
265
564
 
266
- // 7. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
565
+ // 10. interrupted — a signal kill (OOM 137, SIGTERM 143, …) or an explicit
267
566
  // Killed / out-of-memory marker. The real VM state is unknown. Not retryable.
268
567
  if (fc.isSignalKillExit(Number(exitCode)) || /\bKilled\b/.test(log) || /Out of memory/i.test(log)) {
269
568
  return buildFailureVerdict('interrupted', 'Provisioning interrupted',
270
569
  'The provision was interrupted (killed or out of memory), so the VM state is unknown. Reconcile or destroy this VM before re-adding.');
271
570
  }
272
571
 
273
- // 8. converging — the add reached the early-roster marker (`CY_ROSTER`): the
572
+ // 11. The post-add IAP read-back verdicts. Unlike every signature above, this one
573
+ // is not produced by the provision transcript at all — it is the BLOCKED
574
+ // reason iapReadbackOutcome hands to annotateAddFailure, which is what
575
+ // stops a read-back failure from being the one add failure that reaches the
576
+ // card with no category at all (and so no differentiated headline, no
577
+ // recovery, no guarded Retry, and no chance for holdConvergingBadge to hold it
578
+ // calm). Tested AFTER all eight hard-failure signatures so a proven failure
579
+ // always wins if both texts are ever present, and BEFORE converging/verifying
580
+ // because it is strictly more specific than either.
581
+ if (/IAP policy authorizes no human/i.test(log)) {
582
+ const remedy = nextValidActionRemedy(log);
583
+ // 9a. access-pending — no accessors YET. The grant is the LAST step of the
584
+ // detached wire-add-tail, so while that tail is alive an empty policy is
585
+ // the expected mid-flight reading, not a verdict. Calm: this is the state
586
+ // that used to render a red "Add failed" on healthy VM-4/VM-7 minutes
587
+ // before their grant step had even run (2026-08-11).
588
+ if (wiringInFlight) {
589
+ return buildFailureVerdict('access-pending', 'Access not granted yet',
590
+ 'The VM is up and rostered, but its IAP policy has no human accessors yet — the add\'s background wiring applies the grant as its last step and is still running. This resolves on its own once that tail finishes; no action is needed unless it persists past the wiring deadline.');
591
+ }
592
+ // 9b. iap-unauthorized — the wiring is done and the policy STILL authorizes
593
+ // nobody, so the public URL returns "Error code 51" for every human. This
594
+ // is the 2026-08-09 case the read-back exists to catch (one step granted
595
+ // the operator, the next revoked him) and it is a genuine hard failure.
596
+ // Not retryable: the VM exists and is fine — re-adding it would duplicate
597
+ // a live box when what is missing is one grant.
598
+ return buildFailureVerdict('iap-unauthorized', 'No one can reach this VM',
599
+ `The VM is up but its IAP policy authorizes no human, so the public URL returns "Error code 51" for everyone. Its background wiring is no longer running, so this will not resolve on its own — grant access to fix it. Do NOT destroy or re-add this VM.${remedy ? `\n\n${remedy}` : ''}`);
600
+ }
601
+
602
+ // 12. converging — the add reached the early-roster marker (`CY_ROSTER`): the
274
603
  // container gate passed, the instance is real, and the VM is rostered, yet it
275
604
  // exited non-zero WITHOUT matching any specific hard-failure signature above.
276
605
  // By construction of the provision script, every genuine hard failure (drift,
277
- // instance-vanished, container-stuck, incomplete-workspace, cloud-up-failed,
278
- // interrupted) is printed BEFORE `CY_ROSTER`, so a rostered transcript that
606
+ // instance-vanished, container-stuck, incomplete-workspace, capacity-stockout,
607
+ // cloud-up-failed, interrupted) is printed BEFORE `CY_ROSTER`, so a rostered transcript that
279
608
  // fell through to here is a still-wiring box owned by the detached
280
609
  // wire-add-tail / reconcile-ingress — NOT an add failure. Surface it as an
281
610
  // honest in-flight state instead of the scary red `unknown`. Not retryable:
282
611
  // the VM exists and its detached wiring owns finishing it, so a one-click
283
- // retry could duplicate/orphan a live box. Tested LAST so a hard-failure
284
- // signature (all printed pre-roster) always wins — `converging` can never
285
- // shadow a proven failure.
612
+ // retry could duplicate/orphan a live box. Tested after every hard-failure
613
+ // signature (all printed pre-roster) so a proven failure always wins —
614
+ // `converging` can never shadow one.
286
615
  if (transcriptRostered(log)) {
287
616
  return buildFailureVerdict('converging', 'Add still converging',
288
617
  'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
289
618
  }
290
619
 
291
- // 9. The residue: no failure signature above matched. The polarity here is
620
+ // 13. The residue: no failure signature above matched. The polarity here is
292
621
  // INVERTED from the original design. It used to read "no recognized success
293
622
  // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
294
623
  // VM whose container was Up and whose editor answered /api/health 200 got a
@@ -298,7 +627,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
298
627
  //
299
628
  // Red now requires positive proof of death, and everything else is in-flight:
300
629
  //
301
- // 9a. unknown — a non-zero exit CORROBORATED by a durable fact (see
630
+ // 11a. unknown — a non-zero exit CORROBORATED by a durable fact (see
302
631
  // addFailureCorroborated). The box is provably dead or provably never
303
632
  // came up, so the loud, terminal escalation is warranted and unchanged.
304
633
  // Still never invites an unproven retry.
@@ -307,7 +636,7 @@ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy
307
636
  'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
308
637
  }
309
638
 
310
- // 9b. verifying — the honest default. No failure signature, and nothing
639
+ // 11b. verifying — the honest default. No failure signature, and nothing
311
640
  // durable proves this VM is dead: it may still be provisioning or
312
641
  // wiring, and the ingress can keep propagating for minutes after the
313
642
  // wrapper is gone. Calm copy, transcript still one click away. NOT
@@ -358,4 +687,56 @@ function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
358
687
  return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
359
688
  }
360
689
 
361
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };
690
+ // Refusal copy for a Retry on a card whose branch was deleted from origin.
691
+ //
692
+ // The generic non-retryable refusal says the add "may have left a
693
+ // half-provisioned instance — reconcile or destroy it". For this category that
694
+ // is the OPPOSITE of the truth: git aborts before any image build or container
695
+ // work, so nothing exists. Sending an operator to hunt for a box that was never
696
+ // created is what cost the 2026-08-25 recovery its first hour, so this copy
697
+ // leads with the outcome (nothing to clean up) before naming the mechanism.
698
+ //
699
+ // The remedy is deliberately not "retry". Re-issuing the same config against a
700
+ // deleted ref fails identically forever — which is exactly why the category is
701
+ // kept OUT of ADD_FAILURE_RETRYABLE — so the honest advice is a different
702
+ // action. For a self-host VM the fleet pin names the live branch and a re-add
703
+ // now picks it up, so the copy can be specific; for a client VM the branch is a
704
+ // per-project choice only the operator can make, so it asks instead of guessing.
705
+ //
706
+ // `ref` / `field` come from missingBranchRefFacts and are both optional: the copy
707
+ // degrades to true-but-generic rather than interpolating `undefined`, the same
708
+ // discipline stockoutCapacityPhrase uses. Pure; no I/O.
709
+ function deadRefRetryMessage({ n, ref, field, selfHost = false, pin } = {}) {
710
+ const which = ref ? `'${ref}'` : 'the requested branch';
711
+ const carried = field ? ` (${field})` : '';
712
+ const remedy = selfHost && pin
713
+ ? `Add VM again — it now picks up the current fleet branch (${pin}) instead of the stored one.`
714
+ : 'Add VM again with a branch that still exists on origin.';
715
+ return (
716
+ `VM-${n}: branch ${which}${carried} no longer exists on origin, so nothing was provisioned — ` +
717
+ 'there is no instance, disk or backend service to clean up. Retrying as-is re-targets ' +
718
+ `the same dead branch and fails identically every time. ${remedy}`
719
+ );
720
+ }
721
+
722
+ // Refusal copy for a one-click "Retry add" against a slot held by a wedged
723
+ // insert, extracted for the same reason deadRefRetryMessage was: the refusal
724
+ // decision belongs to canRetryAdd, the wording belongs here, and only the wording
725
+ // needs asserting.
726
+ //
727
+ // It exists because the generic refusal line is not merely unhelpful here, it is
728
+ // WRONG in a way the operator cannot detect. "may have left a half-provisioned
729
+ // instance — reconcile or destroy it" describes a cleanup that is accepted by GCE
730
+ // and then silently queues behind the wedged insert instead of clearing it, so
731
+ // following it looks like progress and achieves nothing. Say what is true: this is
732
+ // GCE-side, neither retry nor destroy resolves it, and the unblocking move is a
733
+ // Support case (or a different VM number if a box is needed now). Pure; no I/O.
734
+ function wedgedRetryMessage(n) {
735
+ return (
736
+ `VM-${n} is stuck inside Google Cloud: GCE accepted the create and never finished it, so the instance name is ` +
737
+ 'held by a wedged operation. A retry cannot succeed while it is outstanding, and a destroy will queue behind it ' +
738
+ 'rather than clear it — this needs a Google Cloud Support case. Add on a different VM number if you need a box now.'
739
+ );
740
+ }
741
+
742
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, stockoutCapacityPhrase, missingBranchRefFacts, missingBranchRefRecovery, deadRefRetryMessage, wedgedInsertFacts, wedgedInsertRecovery, wedgedRetryMessage, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };