@codeyam-editor/codeyam-editor 0.1.8 → 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 (35) hide show
  1. package/npm/cloud.js +361 -18
  2. package/npm/editor-dev.js +24 -2
  3. package/npm/editor.js +15 -5
  4. package/npm/fleet-add-failure.js +355 -21
  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-revision.js +108 -0
  10. package/npm/fleet-run-setup.js +66 -0
  11. package/npm/fleet-session-activity.js +162 -0
  12. package/npm/fleet-vm-readiness.js +57 -16
  13. package/npm/launch-config.js +126 -2
  14. package/npm/scenario-check.js +697 -34
  15. package/npm/scenario-interactivity.js +477 -23
  16. package/npm/scenario-issues.js +61 -0
  17. package/npm/scenario-playwright.js +67 -14
  18. package/npm/utils.js +262 -22
  19. package/package.json +5 -5
  20. package/ui/dist/assets/{ContainerInspector-DSVZvEkB.js → ContainerInspector-C7I9wctz.js} +1 -1
  21. package/ui/dist/assets/EditorShell-9aYMeOdZ.js +330 -0
  22. package/ui/dist/assets/EditorShell-CE8iG_EM.css +1 -0
  23. package/ui/dist/assets/ScenarioDataPanel-CLwkrex0.js +18 -0
  24. package/ui/dist/assets/{ScenarioDataPanelFullPage-Cyk6efiI.js → ScenarioDataPanelFullPage-DT0TZC4M.js} +1 -1
  25. package/ui/dist/assets/ScenarioRenderer-Bsarhcrl.js +21 -0
  26. package/ui/dist/assets/index-BRoAenc1.css +1 -0
  27. package/ui/dist/assets/{index-BPLKgwon.js → index-iMR5siiG.js} +9 -9
  28. package/ui/dist/index.html +2 -2
  29. package/ui/dist/preview-subdir-demo/index.html +62 -0
  30. package/ui/dist/preview-subdir-demo/sibling.html +48 -0
  31. package/ui/dist/assets/EditorShell-DnXdHrar.js +0 -256
  32. package/ui/dist/assets/EditorShell-FvbXyqqD.css +0 -1
  33. package/ui/dist/assets/ScenarioDataPanel-6qJE-0lA.js +0 -15
  34. package/ui/dist/assets/ScenarioRenderer-Uyt54vHI.js +0 -21
  35. package/ui/dist/assets/index-DKpgJLo3.css +0 -1
package/npm/cloud.js CHANGED
@@ -514,6 +514,28 @@ function previewSameOriginSafeFromStack(stackJson) {
514
514
  return typeof flag === "boolean" ? flag : true;
515
515
  }
516
516
 
517
+ /** Resolve the sameOriginSafe flag a seed should ACT on, given the static
518
+ * `stack.json` reading and the running editor's own `/api/config` verdict.
519
+ *
520
+ * Precedence: a BOOLEAN effective verdict wins; anything else (`null`,
521
+ * `undefined`, a non-boolean) defers to the static flag. The type check is the
522
+ * whole rule — `null` means "no server was up to ask", which is the
523
+ * pre-container seed and must leave the static read in charge, NOT "the server
524
+ * said same-origin is fine". Collapsing the two would put the static flag back
525
+ * in control on exactly the path that needs the override.
526
+ *
527
+ * Extracted from `seedPreviewOrigin` because that function's inputs arrive over
528
+ * gcloud SSH, so this rule was only reachable through heavy mocking — while
529
+ * being precisely where the defect lived. `stack.json` cannot express the state
530
+ * that matters here: a project escalates off the same-origin subpath at RUNTIME
531
+ * after a hydration failure, and the adaptive-default stacks that happens to
532
+ * (Next.js) deliberately omit the key. So a seeder that re-derives the flag
533
+ * from the file writes back the same-origin sentinel that just failed, and the
534
+ * project sits at `previewDedicatedOriginUnavailable: true` forever. Pure. */
535
+ function resolveEffectiveSameOriginSafe(staticFlag, effectiveVerdict) {
536
+ return typeof effectiveVerdict === "boolean" ? effectiveVerdict : staticFlag;
537
+ }
538
+
517
539
  // Matches an absolute cloudflared quick-tunnel origin
518
540
  // (`https://<sub>.trycloudflare.com`, optionally with a port/path/query). These
519
541
  // URLs ROTATE on every tunnel respawn, which is exactly why a frozen one rots.
@@ -958,6 +980,19 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
958
980
  return ["compute", "instances", "delete", instanceName, "--project", project, "--zone", zone, "--quiet"];
959
981
  case "instances-describe":
960
982
  return ["compute", "instances", "describe", instanceName, "--project", project, "--zone", zone, "--format=json"];
983
+ // Outstanding (not-yet-DONE) zone operations targeting this instance. Used to
984
+ // tell a normal transient PROVISIONING from a wedged insert: GCE can accept an
985
+ // insert and then never finish it, leaving an operation parked at RUNNING/0%
986
+ // that holds the instance name forever. The status filter is what makes this
987
+ // cheap and unambiguous — a DONE operation is history and says nothing about
988
+ // whether the slot is currently blocked.
989
+ case "operations-list-pending":
990
+ return [
991
+ "compute", "operations", "list",
992
+ "--project", project,
993
+ "--filter", `zone:(${zone}) AND targetLink~${instanceName}$ AND status!=DONE`,
994
+ "--format=json",
995
+ ];
961
996
  case "instances-list-tagged":
962
997
  return [
963
998
  "compute", "instances", "list",
@@ -1054,8 +1089,41 @@ function buildGcloudArgs(verb, cfg = {}, extras = {}) {
1054
1089
  // reconcile spawnSync sites (status probe, per-BES timeout converge) so all three
1055
1090
  // reference one value. 2 min is generous for any single describe/update/SSH probe
1056
1091
  // yet comfortably under the 600000 ms outer reconcile budget.
1092
+ //
1093
+ // It is a PROBE budget, and provisioning is exempt — see
1094
+ // GCLOUD_PROVISION_TIMEOUT_MS below. Do not re-collapse the two: raising this
1095
+ // constant to accommodate a slow create would un-bound the reconcile sweep,
1096
+ // which is the exact orphan-cascade this value exists to stop.
1057
1097
  const GCLOUD_CALL_TIMEOUT_MS = 120000;
1058
1098
 
1099
+ // `instances create` / `instances start` are NOT probes: each blocks until GCE's
1100
+ // insert (or start) operation reaches a terminal state, which routinely outruns
1101
+ // the probe budget under zonal contention. Inheriting GCLOUD_CALL_TIMEOUT_MS
1102
+ // SIGTERMd gcloud mid-insert; `spawnSync` then returned `status: null`, which
1103
+ // `!== 0`, so a local kill was indistinguishable from a remote rejection and the
1104
+ // caller threw a bare `Failed to create instance <name>` with no cause in it.
1105
+ // That mattered downstream: every arm of classifyAddFailure keys on a transcript
1106
+ // signature, so a causeless log fell through to the optimistic `verifying`
1107
+ // fallback and told the operator to WAIT on a VM GCE had already wedged
1108
+ // (observed 2026-09-04 on VM-8: insert 14:29:50Z, add errored 14:32:26Z — the
1109
+ // 120s budget plus teardown).
1110
+ //
1111
+ // Deliberately above the 600000 ms outer reconcile budget, on the same reasoning
1112
+ // as PROJECT_BOOTSTRAP_TIMEOUT_MS: ensureInstance is only ever reached from the
1113
+ // add path, never from the reconcile sweep, so the sweep-relative ceiling that
1114
+ // governs GCLOUD_CALL_TIMEOUT_MS does not apply. A create still running at 10
1115
+ // minutes is wedged, not slow.
1116
+ const GCLOUD_PROVISION_TIMEOUT_MS = 10 * 60 * 1000;
1117
+
1118
+ /** The per-call spawn budget a given wrapper verb should run under. Provisioning
1119
+ * verbs get GCLOUD_PROVISION_TIMEOUT_MS; everything else keeps the probe budget.
1120
+ * Pure — the mapping is asserted directly, without spawning a process. */
1121
+ function gcloudCallTimeoutMs(verb) {
1122
+ return verb === "instances-create" || verb === "instances-start"
1123
+ ? GCLOUD_PROVISION_TIMEOUT_MS
1124
+ : GCLOUD_CALL_TIMEOUT_MS;
1125
+ }
1126
+
1059
1127
  // The project bootstrap is NOT a probe: it runs `git clone` + `npm install` in
1060
1128
  // the repo root and every `apps[].dir`, each package's `postinstall`, then
1061
1129
  // `npm run setup` — minutes of remote work on a heavy repo. It once ran as ONE
@@ -1128,6 +1196,66 @@ function formatGcloudFailure(result) {
1128
1196
  return "(no output captured from gcloud)";
1129
1197
  }
1130
1198
 
1199
+ /** The thrown message for a failed `instances create` / `instances start`.
1200
+ *
1201
+ * Splits the two outcomes the old bare `Failed to create instance <name>`
1202
+ * collapsed, because they are different operator actions: a LOCAL timeout means
1203
+ * GCE may still be working the operation and it should be checked, while a
1204
+ * remote rejection means GCE said no and its own stderr is the answer. Both
1205
+ * arms put a cause in the transcript — classifyAddFailure keys on transcript
1206
+ * text, so a causeless message is what lands an add on the optimistic
1207
+ * `verifying` fallback. Pure: result in, string out.
1208
+ *
1209
+ * `verb` is the operator-facing word ("create" / "start"), not a wrapper verb. */
1210
+ /** Operator-facing copy for a create GCE accepted and then abandoned, kept
1211
+ * separate from the guard that detects it for the same reason
1212
+ * provisionFailureMessage is: the decision of WHEN to refuse and the wording of
1213
+ * WHAT to say are different concerns, and only the wording needs asserting.
1214
+ * Extracting it is what lets the copy be tested without a live gcloud — and on
1215
+ * this feature the copy IS the fix, since the bug was a message that told the
1216
+ * operator to wait for a VM that would never arrive.
1217
+ *
1218
+ * Every field is rendered defensively because this runs on a degraded path: the
1219
+ * operations read that produced `pending` is best-effort, so a partial record
1220
+ * must still yield a message that names the problem rather than printing
1221
+ * "undefined" at the operator. */
1222
+ function wedgedOperationMessage(instanceName, pending = {}) {
1223
+ const detail = [
1224
+ `${pending.name || "unnamed"}: ${pending.operationType || "unknown type"}`,
1225
+ `status ${pending.status || "unknown"}`,
1226
+ `progress ${pending.progress == null ? "unknown" : `${pending.progress}%`}`,
1227
+ `error ${pending.errorCode || "unknown"}${pending.errorMessage ? `: ${pending.errorMessage}` : ""}`,
1228
+ ].join(", ");
1229
+ return (
1230
+ `Instance ${instanceName} is stuck in PROVISIONING behind a wedged create operation (${detail}). ` +
1231
+ "GCE accepted the create and never finished it, so this slot cannot be re-added and a delete will queue " +
1232
+ "behind the wedged operation rather than clear it. This needs GCE-side resolution — open a Google Cloud " +
1233
+ "Support case citing that error. Use a different VM number if you need a box now."
1234
+ );
1235
+ }
1236
+
1237
+ function provisionFailureMessage(verb, instanceName, result) {
1238
+ if (isSpawnTimeout(result)) {
1239
+ const minutes = Math.round(GCLOUD_PROVISION_TIMEOUT_MS / 60000);
1240
+ // A SIGTERMd child legitimately often captured nothing, and appending
1241
+ // formatGcloudFailure's "(no output captured)" placeholder to a message that
1242
+ // already states the cause is noise. Append it only when it is real output.
1243
+ const detail = formatGcloudFailure(result);
1244
+ const captured = detail === "(no output captured from gcloud)" ? "" : `\n${detail}`;
1245
+ return (
1246
+ `Failed to ${verb} instance ${instanceName}: the gcloud call exceeded the local ` +
1247
+ `${minutes}-minute provisioning budget and was SIGTERMd locally. GCE may still be ` +
1248
+ `working the operation — check it with \`gcloud compute operations list\` before ` +
1249
+ `retrying, and delete the half-built instance if one exists.` +
1250
+ captured
1251
+ );
1252
+ }
1253
+ return (
1254
+ `Failed to ${verb} instance ${instanceName} (gcloud exit ${result && result.status}).\n` +
1255
+ formatGcloudFailure(result)
1256
+ );
1257
+ }
1258
+
1131
1259
  // A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
1132
1260
  // for an ssh failure) and a signature match against known transient strings.
1133
1261
  function isTransientSshFailure(result) {
@@ -1203,6 +1331,38 @@ function fetchInstanceState(cfg) {
1203
1331
  }
1204
1332
  }
1205
1333
 
1334
+ /** Read the instance's outstanding (not-DONE) zone operations and summarize the
1335
+ * first one, or null when there are none / the call failed. Shaped as
1336
+ * { name, operationType, status, progress, errorCode, errorMessage } — the fields
1337
+ * the wedged-insert refusal message and the fleet classifier both need.
1338
+ *
1339
+ * Returning null on a failed or unparseable call is deliberate: this is a
1340
+ * DIAGNOSTIC read used to make a refusal more informative, so it must never turn
1341
+ * a working provision into a failing one just because the operations API hiccuped. */
1342
+ function fetchPendingOperation(cfg) {
1343
+ const result = runGcloud(buildGcloudArgs("operations-list-pending", cfg), {
1344
+ stdio: ["ignore", "pipe", "ignore"],
1345
+ });
1346
+ if (result.status !== 0) return null;
1347
+ let ops;
1348
+ try {
1349
+ ops = JSON.parse(result.stdout);
1350
+ } catch {
1351
+ return null;
1352
+ }
1353
+ if (!Array.isArray(ops) || ops.length === 0) return null;
1354
+ const op = ops[0];
1355
+ const err = op && op.error && Array.isArray(op.error.errors) ? op.error.errors[0] : null;
1356
+ return {
1357
+ name: op.name || null,
1358
+ operationType: op.operationType || null,
1359
+ status: op.status || null,
1360
+ progress: typeof op.progress === "number" ? op.progress : null,
1361
+ errorCode: (err && err.code) || null,
1362
+ errorMessage: (err && err.message) || null,
1363
+ };
1364
+ }
1365
+
1206
1366
  function loadOrInitCloudConfig(projectDir, flags) {
1207
1367
  const existing = loadCloudConfig(projectDir);
1208
1368
  // noTunnel is a per-invocation behavior, never persisted — else a later manual
@@ -1284,7 +1444,11 @@ async function ensureFirewallRule(cfg) {
1284
1444
  }
1285
1445
  }
1286
1446
 
1287
- async function ensureInstance(cfg) {
1447
+ // `sleepFn` is injectable so the 60-iteration wait loop can be driven with a
1448
+ // no-op instead of burning 2 real minutes of clock — the same seam
1449
+ // runGcloudSshWithRetry / waitForEditorComposeRunning already expose.
1450
+ async function ensureInstance(cfg, { sleepFn } = {}) {
1451
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
1288
1452
  console.error(`Checking instance ${cfg.instanceName}...`);
1289
1453
  const initialState = fetchInstanceState(cfg);
1290
1454
  const createdThisRun = initialState === null;
@@ -1295,29 +1459,59 @@ async function ensureInstance(cfg) {
1295
1459
  }
1296
1460
  if (initialState === "TERMINATED") {
1297
1461
  console.error(` ${cfg.instanceName} is TERMINATED — starting...`);
1298
- const start = runGcloud(buildGcloudArgs("instances-start", cfg));
1462
+ const start = runGcloud(buildGcloudArgs("instances-start", cfg), {
1463
+ timeout: gcloudCallTimeoutMs("instances-start"),
1464
+ });
1299
1465
  if (start.status !== 0) {
1300
- throw new Error(`Failed to start instance ${cfg.instanceName}`);
1466
+ throw new Error(provisionFailureMessage("start", cfg.instanceName, start));
1301
1467
  }
1302
1468
  } else if (initialState === null) {
1303
1469
  console.error(` ${cfg.instanceName} not found — creating...`);
1304
- const create = runGcloud(buildGcloudArgs("instances-create", cfg));
1470
+ const create = runGcloud(buildGcloudArgs("instances-create", cfg), {
1471
+ timeout: gcloudCallTimeoutMs("instances-create"),
1472
+ });
1305
1473
  if (create.status !== 0) {
1306
- throw new Error(`Failed to create instance ${cfg.instanceName}`);
1474
+ throw new Error(provisionFailureMessage("create", cfg.instanceName, create));
1307
1475
  }
1308
1476
  } else {
1477
+ // A slot found in PROVISIONING is ambiguous, and the two readings need
1478
+ // opposite handling. On a normal add it is a transient the poll loop below
1479
+ // resolves in seconds. But GCE can also accept an insert and then never
1480
+ // finish it (2026-09-04, VM-8): the operation parks at RUNNING/0%, eventually
1481
+ // attaches an INTERNAL_ERROR without ever reaching DONE, and holds the
1482
+ // instance name indefinitely — a delete queues behind it rather than clearing
1483
+ // it. Polling that for 2 minutes only rediscovers, slowly and with a message
1484
+ // naming neither the operation nor the wedge, a state the first describe
1485
+ // already showed. Read the outstanding operation instead and fail immediately
1486
+ // when it carries a terminal error, so the transcript carries the signature
1487
+ // fleet-add-failure.js's wedged-insert arm keys on.
1488
+ if (initialState === "PROVISIONING") {
1489
+ const pending = fetchPendingOperation(cfg);
1490
+ if (pending && pending.errorCode) {
1491
+ throw new Error(wedgedOperationMessage(cfg.instanceName, pending));
1492
+ }
1493
+ }
1309
1494
  console.error(` ${cfg.instanceName} is ${initialState}; waiting...`);
1310
1495
  }
1311
1496
 
1497
+ let lastState = initialState;
1312
1498
  for (let i = 0; i < 60; i++) {
1313
- if (fetchInstanceState(cfg) === "RUNNING") {
1499
+ lastState = fetchInstanceState(cfg);
1500
+ if (lastState === "RUNNING") {
1314
1501
  console.error(` ✓ ${cfg.instanceName} is RUNNING`);
1315
1502
  await waitForIapReady(cfg);
1316
1503
  return { createdThisRun };
1317
1504
  }
1318
- await new Promise((r) => setTimeout(r, 2000));
1505
+ await sleep(2000);
1319
1506
  }
1320
- throw new Error(`Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes`);
1507
+ // Name the state it was actually stuck in. An instance parked in PROVISIONING
1508
+ // is a different operator action from one that stopped answering describe at
1509
+ // all, and the old message ("did not reach RUNNING") distinguished neither.
1510
+ throw new Error(
1511
+ `Instance ${cfg.instanceName} did not reach RUNNING within 2 minutes ` +
1512
+ `(last observed state: ${lastState || "unknown — describe returned no state"}` +
1513
+ `${createdThisRun ? "; a create was issued this run" : ""}).`,
1514
+ );
1321
1515
  }
1322
1516
 
1323
1517
  /** GCE reports the instance as RUNNING the moment the boot disk is attached,
@@ -1785,17 +1979,32 @@ function projectBootstrapRunnerScript(repoName) {
1785
1979
  // makes the outcome probe honest: after a launch, a present `.done` can only
1786
1980
  // mean THIS run's script reached its terminal line, never a leftover from a
1787
1981
  // previous half-wired attempt.
1788
- function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName) {
1982
+ function projectBootstrapLaunchCommand(scriptBody, scriptArgs = [], envVars = {}, repoName, libFiles = {}) {
1789
1983
  const scriptB64 = Buffer.from(scriptBody, "utf8").toString("base64");
1790
1984
  const runnerB64 = Buffer.from(projectBootstrapRunnerScript(repoName), "utf8").toString("base64");
1791
1985
  const argsString = scriptArgs.map(shQuote).join(" ");
1792
1986
  const envPrefix = envAssignmentPrefix(envVars);
1987
+ // The bootstrap script is written STANDALONE to $HOME, so any `source`/`.` it
1988
+ // does relative to its own directory resolves against $HOME — not against the
1989
+ // repo layout it was authored in. `libFiles` carries those siblings, keyed by
1990
+ // the $HOME-relative path the script will look for them at (e.g.
1991
+ // "lib/npm-pin.sh"). Their directories do not exist on a fresh VM, hence the
1992
+ // mkdir. Omitting a sourced library is not a soft failure: the script aborts
1993
+ // under `set -euo pipefail` and cloud:up tears the new VM down, which is the
1994
+ // 2026-09-04 fleet-wide add outage.
1995
+ const libWrites = Object.entries(libFiles).flatMap(([relPath, body]) => {
1996
+ const b64 = Buffer.from(body, "utf8").toString("base64");
1997
+ const dir = path.posix.dirname(relPath);
1998
+ const mkdir = dir && dir !== "." ? [`mkdir -p $HOME/${dir}`] : [];
1999
+ return [...mkdir, `echo '${b64}' | base64 -d > $HOME/${relPath}`];
2000
+ });
1793
2001
  // Writes run synchronously (fast, before any backgrounding) so a decode error
1794
2002
  // aborts before launch; then the runner is detached. The env prefix is on the
1795
2003
  // setsid command so the tokens are inherited through nohup→setsid→bash but
1796
2004
  // never appear in any argv. `& echo MARKER` (no `;` before echo) keeps the
1797
2005
  // launch in the background while still confirming it fired.
1798
2006
  const writes = [
2007
+ ...libWrites,
1799
2008
  `echo '${scriptB64}' | base64 -d > ${projectBootstrapScriptPath(repoName)}`,
1800
2009
  `echo '${runnerB64}' | base64 -d > ${projectBootstrapRunnerPath(repoName)}`,
1801
2010
  `rm -f ${projectBootstrapStatusPath(repoName)} ${projectBootstrapDoneMarker(repoName)}`,
@@ -2027,6 +2236,13 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2027
2236
  console.error(` streaming scripts/gcp-project-bootstrap.sh to VM for ${cfg.repo}...`);
2028
2237
  const scriptPath = path.join(rootDir, "scripts", "gcp-project-bootstrap.sh");
2029
2238
  const scriptBody = readStreamedScript(scriptPath, cfg);
2239
+ // gcp-project-bootstrap.sh sources lib/npm-pin.sh relative to its own
2240
+ // directory. Read it through readStreamedScript too, so the library is subject
2241
+ // to the same drift guard as the script that sources it — streaming a reviewed
2242
+ // remote script beside a stale on-disk library would defeat the guard.
2243
+ const bootstrapLibs = {
2244
+ "lib/npm-pin.sh": readStreamedScript(path.join(rootDir, "scripts", "lib", "npm-pin.sh"), cfg),
2245
+ };
2030
2246
  const scriptArgs = clientProjectBootstrapArgs(cfg);
2031
2247
  // Git identity, forwarded launch-form keys, fleet-domain ingress and the admin
2032
2248
  // toggle — see projectBootstrapEnv for what each key is for and when it is
@@ -2054,7 +2270,7 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2054
2270
  // install that will survive an SSH disconnect. Every step of
2055
2271
  // gcp-project-bootstrap.sh is skip-on-exists, so re-entering is cheap.
2056
2272
  console.error(" launching detached project bootstrap on the VM (survives SSH disconnect)...");
2057
- const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName);
2273
+ const launch = projectBootstrapLaunchCommand(scriptBody, scriptArgs, bootstrapEnv, repoName, bootstrapLibs);
2058
2274
  const launched = await runGcloudSshWithRetry(
2059
2275
  "ssh-script",
2060
2276
  cfg,
@@ -2805,8 +3021,30 @@ function buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }) {
2805
3021
  * `_managedPreviewOrigin` sentinel). We stamp the sentinel here too so the
2806
3022
  * monitor doesn't mistake the cloud:up seed for a user pin. The
2807
3023
  * skip-on-already-set guard stays: a non-empty previewOrigin (whether a user
2808
- * pin or a monitor write) is left untouched on every `cloud:up`. */
2809
- async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
3024
+ * pin or a monitor write) is left untouched on every `cloud:up`.
3025
+ *
3026
+ * `effectiveSameOriginSafe` is the RUNNING editor's own verdict
3027
+ * (`/api/config`'s `previewSameOriginSafe`), supplied by a caller that has
3028
+ * already fetched it. When it is a boolean it WINS over the static
3029
+ * `stack.json` read; `null`/absent leaves the static read in charge.
3030
+ *
3031
+ * That parameter exists because this function had two sources of truth for one
3032
+ * flag, and they disagreed exactly where the adaptive default lives. A
3033
+ * subpath hydration failure escalates a project off `/__codeyam_preview` at
3034
+ * RUNTIME even when `stack.json` never declared `sameOriginSafe: false` — which
3035
+ * is the documented Next.js scaffold shape (a static `false` broke every
3036
+ * SSH-forward VM, so the key is deliberately absent). `healPreviewOriginAtReady`
3037
+ * decided WHETHER to heal from that runtime verdict and was right, then called
3038
+ * this function, which recomputed WHAT to write from the static flag and wrote
3039
+ * the very same-origin sentinel that had just failed. The project then sat
3040
+ * permanently at `previewDedicatedOriginUnavailable: true` — the editor knowing
3041
+ * it must leave the subpath and having nowhere to go — beside a preview host
3042
+ * that was already provisioned, LB-routed, HEALTHY and unused. */
3043
+ async function seedPreviewOrigin(
3044
+ cfg,
3045
+ appPort = VITE_PORT,
3046
+ effectiveSameOriginSafe = null,
3047
+ ) {
2810
3048
  const repoName = resolveProject(cfg).name;
2811
3049
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
2812
3050
  const stackPath = `~/projects/${repoName}/.codeyam/stack.json`;
@@ -2842,6 +3080,12 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
2842
3080
  current = parseObj(localJson);
2843
3081
  sameOriginSafe = previewSameOriginSafeFromStack(parseObj(stackJson));
2844
3082
  }
3083
+ // The running editor's verdict overrides the static file — see
3084
+ // `resolveEffectiveSameOriginSafe` for why only a boolean counts.
3085
+ sameOriginSafe = resolveEffectiveSameOriginSafe(
3086
+ sameOriginSafe,
3087
+ effectiveSameOriginSafe,
3088
+ );
2845
3089
 
2846
3090
  // Resolve this VM's custom slug (if any) so a dedicated-origin seed uses the
2847
3091
  // slug host. The slug rides on cfg at Add time (the --vm-slug flag, validated
@@ -2875,6 +3119,12 @@ async function seedPreviewOrigin(cfg, appPort = VITE_PORT) {
2875
3119
  // `sameOriginSafe: true`, and seeded `/__codeyam_preview` — which a
2876
3120
  // remote browser then loads as `127.0.0.1 refused to connect`). Once
2877
3121
  // stack.json exists, heal it forward to the dedicated origin.
3122
+ // `sameOriginSafe` here is the EFFECTIVE verdict, so this exception also
3123
+ // covers the case stack.json can never report: a project whose static
3124
+ // flag is legitimately absent (the Next.js scaffold default) that
3125
+ // escalated off the subpath at runtime after a hydration failure. The
3126
+ // caller passes the editor's own answer; without it this predicate read
3127
+ // `true` and declined to heal the one shape it was needed for.
2878
3128
  // Because every dashboard action (Reset / Update / Add / bootstrap-recovery)
2879
3129
  // re-runs this seed over cloud:up, either heal reaches every touch-point the
2880
3130
  // moment stack.json is present — closing the race without re-ordering
@@ -2970,11 +3220,14 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT) {
2970
3220
  `READY auto-heal: ${cfg.instanceName} reports a not-same-origin-safe stack ` +
2971
3221
  `but a wrong-mode previewOrigin (${config.previewOrigin}); re-seeding...`,
2972
3222
  );
2973
- // Re-run the seed: stack.json is now readable, so the wrong-mode heal writes
2974
- // the dedicated origin into editor.local.json (a published `<N>-preview`
2975
- // tunnel URL, else the localhost bootstrap for the window before the monitor's
2976
- // first tick).
2977
- await seedPreviewOrigin(cfg, appPort);
3223
+ // Re-run the seed with the verdict we just read, so WHETHER to heal and WHAT
3224
+ // to write come from the same source. Passing it is what makes this heal reach
3225
+ // the adaptive-default stacks it exists for: on a Next.js project whose
3226
+ // `stack.json` omits `sameOriginSafe` (the scaffolded shape), the static read
3227
+ // inside the seed answers `true` and re-writes the same-origin sentinel that
3228
+ // just failed to hydrate. We got here precisely BECAUSE the editor said
3229
+ // `false`, so `false` is the answer the seed must use.
3230
+ await seedPreviewOrigin(cfg, appPort, false);
2978
3231
  // Tell the running server to re-read editor.local.json — no restart (the
2979
3232
  // footgun that killed VM7's live Claude session).
2980
3233
  const reload = runGcloud(
@@ -3372,7 +3625,16 @@ function promptForInstanceName(expected) {
3372
3625
  });
3373
3626
  }
3374
3627
 
3375
- const STREAMED_SCRIPTS = ["scripts/gcp-bootstrap.sh", "scripts/gcp-project-bootstrap.sh"];
3628
+ // Every file streamed to a VM, and therefore every file the drift guard must
3629
+ // compare against the reviewed remote tip. `scripts/lib/npm-pin.sh` is here
3630
+ // because gcp-project-bootstrap.sh SOURCES it: a library that reaches the VM is
3631
+ // as much streamed content as the script that sources it, and leaving it out
3632
+ // meant the drift guard silently vouched for only half of what was shipped.
3633
+ const STREAMED_SCRIPTS = [
3634
+ "scripts/gcp-bootstrap.sh",
3635
+ "scripts/gcp-project-bootstrap.sh",
3636
+ "scripts/lib/npm-pin.sh",
3637
+ ];
3376
3638
 
3377
3639
  /** The configured editor branch to compare/stream streamed scripts against.
3378
3640
  * Resolved from config (`resolveEditorConfig` → `editorBranch`, itself derived
@@ -4202,6 +4464,75 @@ function releaseVmSlugOnDestroy(cfg) {
4202
4464
  return vmNumber;
4203
4465
  }
4204
4466
 
4467
+ /** Does `branch` still exist on `repo`? Answered with a single
4468
+ * `git ls-remote --heads` — about a second, and no cloud resource touched.
4469
+ *
4470
+ * Tri-state on purpose, and the third state is the load-bearing one:
4471
+ * true — the ref resolved.
4472
+ * false — the remote answered and the ref is NOT there. Provably gone.
4473
+ * null — we could not get an answer (no network, no credentials, git
4474
+ * missing, timeout). NOT evidence of a deleted branch.
4475
+ * Only `false` refuses an add. Collapsing `null` into `false` would turn every
4476
+ * transient GitHub blip into a fleet-wide add outage, which is a far worse
4477
+ * failure than the one this preflight exists to prevent — and the classifier
4478
+ * arm downstream still catches a genuinely dead ref, just later and dearer.
4479
+ *
4480
+ * A private repo needs credentials, so the token is inlined into the URL the
4481
+ * same way clone_editor_repo does it on the VM side (x-access-token), and for
4482
+ * the same reason: the plain URL 401s. The token is confined to this argv and
4483
+ * never logged — the caller prints `repo` (the un-inlined form) instead. */
4484
+ function gitBranchRefExists({ repo, branch }) {
4485
+ if (!repo || !branch) return null;
4486
+ let url = repo;
4487
+ const token = resolveGithubToken();
4488
+ if (token && /^https:\/\/github\.com\//.test(repo)) {
4489
+ url = repo.replace("https://", `https://x-access-token:${token}@`);
4490
+ }
4491
+ const res = spawnSync("git", ["ls-remote", "--heads", url, branch], {
4492
+ encoding: "utf8",
4493
+ timeout: 30000,
4494
+ });
4495
+ // A non-zero exit is an unreachable/unauthorized remote, not an absent branch:
4496
+ // git exits 0 and prints nothing for "connected fine, no such head".
4497
+ if (res.error || res.status !== 0) return null;
4498
+ return String(res.stdout || "").trim().length > 0;
4499
+ }
4500
+
4501
+ /** Refuse an add whose stored branch has been deleted from origin, BEFORE the
4502
+ * first gcloud call.
4503
+ *
4504
+ * Without this the failure lands as late and as expensively as it possibly can:
4505
+ * on 2026-08-25 four fleet VMs each created a real GCE instance, booted it,
4506
+ * installed npm and cloned 13,226 files before `git fetch` inside the VM said
4507
+ * `fatal: couldn't find remote ref editor-improvements-75` — a branch pruned
4508
+ * from origin hours earlier when it merged. One `git ls-remote` establishes the
4509
+ * same fact in about a second, for free. This repo prunes branches on merge, so
4510
+ * ANY stored feature-branch name expires silently; that makes this a recurring
4511
+ * condition, not a one-off.
4512
+ *
4513
+ * Placed beside checkScriptDrift and assertVariantProvisionable — the other
4514
+ * "fail loud before creating a GCE instance" guards — because it is only worth
4515
+ * anything ahead of ensureInstance. It cannot live in scripts/gcp-bootstrap.sh,
4516
+ * which already runs ON the provisioned box and never sees the client branch.
4517
+ *
4518
+ * Throws with the field, the branch and the repo named. Never throws for a ref
4519
+ * it could not check (see gitBranchRefExists). */
4520
+ function assertBranchRefsExist(cfg, { refExists = gitBranchRefExists } = {}) {
4521
+ // Lazy require: launch-config.js requires THIS module at load time, so a
4522
+ // top-level require here would close the cycle. Same pattern as the
4523
+ // isPlaceholderProject call sites above.
4524
+ const lc = require("./launch-config");
4525
+ const { editorRepo, editorBranch } = resolveEditorConfig(cfg);
4526
+ const targets = lc.branchRefTargets(
4527
+ { clientRepo: cfg.repo, clientBranch: cfg.branch, editorBranch },
4528
+ { editorRepo },
4529
+ );
4530
+ if (!targets.length) return;
4531
+ console.error("Checking that the requested branches still exist on origin...");
4532
+ const missing = lc.missingBranchRefs(targets, refExists);
4533
+ if (missing.length) throw new Error(lc.missingBranchRefMessage(missing));
4534
+ }
4535
+
4205
4536
  async function cmdUp(args, projectDir = rootDir) {
4206
4537
  ensureGcloud();
4207
4538
  const flags = parseFlags(args);
@@ -4256,6 +4587,10 @@ async function cmdUp(args, projectDir = rootDir) {
4256
4587
 
4257
4588
  checkScriptDrift(cfg);
4258
4589
 
4590
+ // Last of the pre-instance guards: a deleted branch ref is checkable in ~1s
4591
+ // and otherwise costs a full instance + boot + 13k-file clone to discover.
4592
+ assertBranchRefsExist(cfg);
4593
+
4259
4594
  await ensureFirewallRule(cfg);
4260
4595
  const { createdThisRun } = await ensureInstance(cfg);
4261
4596
 
@@ -5299,6 +5634,9 @@ async function cloudMain() {
5299
5634
  module.exports = {
5300
5635
  // pure helpers
5301
5636
  isSpawnTimeout,
5637
+ gcloudCallTimeoutMs,
5638
+ provisionFailureMessage,
5639
+ GCLOUD_PROVISION_TIMEOUT_MS,
5302
5640
  agentEnvVarName,
5303
5641
  agentNpmPackage,
5304
5642
  AGENT_ENV_VARS,
@@ -5334,6 +5672,7 @@ module.exports = {
5334
5672
  resolveConfiguredAppPort,
5335
5673
  resolveSeedPreviewOrigin,
5336
5674
  previewSameOriginSafeFromStack,
5675
+ resolveEffectiveSameOriginSafe,
5337
5676
  isRottedManagedPreviewOrigin,
5338
5677
  isWrongModePreviewOrigin,
5339
5678
  isSilentlyBrokenPreview,
@@ -5392,6 +5731,8 @@ module.exports = {
5392
5731
  FORWARD_ENV_KEYS_ENV,
5393
5732
  parseEditorComposeState,
5394
5733
  fetchInstanceState,
5734
+ fetchPendingOperation,
5735
+ wedgedOperationMessage,
5395
5736
  formatGcloudFailure,
5396
5737
  isTransientSshFailure,
5397
5738
  runGcloudSshWithRetry,
@@ -5449,6 +5790,8 @@ module.exports = {
5449
5790
  readVmAppPort,
5450
5791
  buildVmAppPortReadCommand,
5451
5792
  resolveLaptopGitIdentity,
5793
+ gitBranchRefExists,
5794
+ assertBranchRefsExist,
5452
5795
  };
5453
5796
 
5454
5797
  if (require.main === module) {
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
  }