@codeyam-editor/codeyam-editor 0.1.14-staging.ge9fdd0f → 0.1.14

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 +223 -89
  2. package/npm/fleet-api-budget.js +21 -8
  3. package/npm/fleet-dev-server-health.js +86 -0
  4. package/npm/fleet-host-busy.js +52 -0
  5. package/npm/fleet-invoked-by.js +20 -0
  6. package/npm/fleet-queue.js +12 -2
  7. package/npm/fleet-status-refs.js +47 -0
  8. package/npm/google-ingress.js +84 -0
  9. package/npm/scenario-check.js +682 -26
  10. package/npm/scenario-metrics.js +64 -0
  11. package/npm/scenario-mocks.js +4 -0
  12. package/npm/scenario-playwright.js +313 -10
  13. package/npm/utils.js +2 -0
  14. package/package.json +5 -5
  15. package/ui/dist/assets/{ContainerInspector-DhV9O4qu.js → ContainerInspector-9w8TlRwl.js} +2 -2
  16. package/ui/dist/assets/CyLogo-BIbFVvgW.js +1 -0
  17. package/ui/dist/assets/EditorShell-CmEoMxKh.js +366 -0
  18. package/ui/dist/assets/EditorShell-DTyhLLc8.css +1 -0
  19. package/ui/dist/assets/Markdown-BaLq9qkt.js +1 -0
  20. package/ui/dist/assets/ProjectLauncherScreen-DP25uSQm.js +1 -0
  21. package/ui/dist/assets/ScenarioDataPanel-BM7COEfv.js +21 -0
  22. package/ui/dist/assets/{ScenarioDataPanelFullPage-DIm-kvtz.js → ScenarioDataPanelFullPage-DkysGs6X.js} +1 -1
  23. package/ui/dist/assets/ScenarioRenderer-M3HRiuEn.js +26 -0
  24. package/ui/dist/assets/{StepsSimulator-CymGgfbu.js → StepsSimulator-CcD_CR_X.js} +1 -1
  25. package/ui/dist/assets/index-Bk0fHYF3.css +1 -0
  26. package/ui/dist/assets/index-CqKB9WVm.js +27 -0
  27. package/ui/dist/index.html +2 -2
  28. package/ui/dist/assets/EditorShell-LcMPxnNk.js +0 -286
  29. package/ui/dist/assets/EditorShell-lndJDLqT.css +0 -1
  30. package/ui/dist/assets/Markdown-Chgdf3PW.js +0 -1
  31. package/ui/dist/assets/ProjectLauncherScreen-E0NYYiwy.js +0 -1
  32. package/ui/dist/assets/ScenarioDataPanel-DSW29BTV.js +0 -21
  33. package/ui/dist/assets/ScenarioRenderer-DDT3dneh.js +0 -26
  34. package/ui/dist/assets/index-4TZsSjHA.css +0 -1
  35. package/ui/dist/assets/index-BDc44VP_.js +0 -27
package/npm/cloud.js CHANGED
@@ -935,6 +935,62 @@ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInc
935
935
  return "delete";
936
936
  }
937
937
 
938
+ /** Fold what the abort-causing error proves into the probed VM state, giving
939
+ * the full {@link abortTeardownDecision} input (minus `createdThisRun`).
940
+ *
941
+ * - `remoteCommandNeverRan` — set by a fatal ssh step that classified a
942
+ * transport failure, or sniffed from a message that carries the quota text.
943
+ * - `sshCauseUnknown` — a fatal ssh step could not establish whether its
944
+ * command ran. That is absence of evidence too, so it also raises
945
+ * `probeInconclusive` (fail SAFE) rather than adding a parallel verdict input.
946
+ * Pure: `(err, state) → state`, so the err → verdict path is testable without
947
+ * running cloud:up. */
948
+ function abortEvidence(err, state = {}) {
949
+ const remoteCommandNeverRan =
950
+ Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
951
+ const sshCauseUnknown = Boolean(err && err.sshCauseUnknown);
952
+ return {
953
+ ...state,
954
+ probeInconclusive: Boolean(state.probeInconclusive) || sshCauseUnknown,
955
+ remoteCommandNeverRan,
956
+ sshCauseUnknown,
957
+ };
958
+ }
959
+
960
+ /** The operator-facing line for a VM this run created but the abort handler
961
+ * KEPT. Deliberately loud and specific: the old blanket "tearing down the VM
962
+ * cloud:up created this run (no orphan left behind)" was actively misleading —
963
+ * it asserted no orphan while deleting a working VM. When nothing was learned
964
+ * about the VM (the step never ran, or its cause could not be established), the
965
+ * likely-right next move is to finish provisioning it in place, so the line
966
+ * names that recovery (`converge-vm.sh <n>` for a fleet VM). `state` is
967
+ * {@link abortEvidence}'s output. Pure. */
968
+ function abortKeptMessage({ instanceName, vmNumber, state = {} } = {}) {
969
+ const why = state.remoteCommandNeverRan
970
+ ? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
971
+ : state.sshCauseUnknown
972
+ ? "the failing ssh step could not establish its cause (the command may never have run), so nothing shows the VM is broken"
973
+ : state.probeInconclusive
974
+ ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
975
+ : state.containerUp
976
+ ? "its editor container is RUNNING — another run brought it up"
977
+ : "it is in the fleet roster and visible to the dashboard";
978
+ const nothingLearned = state.remoteCommandNeverRan || state.sshCauseUnknown;
979
+ const recover = nothingLearned
980
+ ? ` To finish provisioning it in place: ${
981
+ vmNumber != null
982
+ ? `scripts/operator/converge-vm.sh ${vmNumber}`
983
+ : `npm run cloud:up -- --instance-name ${instanceName}`
984
+ }\n`
985
+ : "";
986
+ return (
987
+ `✗ provisioning aborted — the VM ${instanceName} was LEFT IN PLACE because ${why}.\n` +
988
+ ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
989
+ recover +
990
+ ` If it really should go, remove it deliberately: npm run cloud:destroy`
991
+ );
992
+ }
993
+
938
994
  /** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
939
995
  * abort handler reads gather → decide → act instead of inlining probes among
940
996
  * the delete calls.
@@ -1340,6 +1396,80 @@ async function runGcloudSshWithRetry(verb, cfg, extras, { stdio, retries = 5, la
1340
1396
  return lastResult;
1341
1397
  }
1342
1398
 
1399
+ /** What a failed ssh step proves about the VM, from its captured result:
1400
+ * - `'transport'` — the gcloud client or the ssh connection failed before the
1401
+ * remote command ran (an OS Login quota 429, a refused/reset connection), so
1402
+ * nothing about the VM has been learned.
1403
+ * - `'unknown'` — ssh itself exited 255 with no recognized signature, the call
1404
+ * was killed/never spawned (no status), or gcloud reported an error of its
1405
+ * own (`ERROR: (gcloud.…)` — an auth or IAP refusal exits 1 like a remote
1406
+ * command would). Whether the remote command ran cannot be established.
1407
+ * - `'remote'` — the remote command ran and exited non-zero: real evidence.
1408
+ * Pure, so the matrix is testable without a VM. Only meaningful for a non-zero
1409
+ * result; the caller has already branched on success. */
1410
+ function classifySshStepFailure(result) {
1411
+ if (isTransientSshFailure(result)) return "transport";
1412
+ if (!result || result.status == null || result.status === 255) return "unknown";
1413
+ if (/ERROR: \(gcloud\./.test(String(result.stderr || ""))) return "unknown";
1414
+ return "remote";
1415
+ }
1416
+
1417
+ /** Run a provisioning ssh step whose failure is FATAL — it throws, and cloud:up's
1418
+ * abort handler then decides whether to delete the VM. Two guards keep that
1419
+ * decision honest, and every fatal step needs both (VM-8 on 2026-10-01 and VM-37
1420
+ * on 2026-10-08 were the same OS Login 429 at two different steps, a week apart,
1421
+ * each at a call site that had neither):
1422
+ *
1423
+ * 1. The retry wrapper, so a transient quota 429 is retried instead of being
1424
+ * fatal on first occurrence.
1425
+ * 2. CAPTURED stderr, never `"inherit"` — a step that cannot see why it failed
1426
+ * cannot classify the failure. The streams are echoed immediately so the
1427
+ * operator's view is kept (`echoStdout` for steps whose stdout used to go to
1428
+ * the terminal; steps that parse stdout leave it off).
1429
+ *
1430
+ * On failure the thrown error carries the classification for
1431
+ * abortTeardownDecision: `remoteCommandNeverRan` for a transport failure,
1432
+ * `sshCauseUnknown` when it cannot be established, and nothing for a genuine
1433
+ * remote failure — so a really broken VM still takes the destructive path. A
1434
+ * remote failure keeps `failureMessage` verbatim. */
1435
+ async function runFatalSshStep(cfg, command, { label, failureMessage, echoStdout = false, sleepFn } = {}) {
1436
+ const result = await runGcloudSshWithRetry(
1437
+ "ssh-probe",
1438
+ cfg,
1439
+ { command },
1440
+ { stdio: ["ignore", "pipe", "pipe"], label, sleepFn },
1441
+ );
1442
+ // Both echoes go to OUR stderr, as at the compose-up site: this process's
1443
+ // stdout carries the machine-read `CY_DEGRADED` lines the dashboard scans.
1444
+ if (echoStdout && result.stdout) process.stderr.write(result.stdout);
1445
+ if (result.stderr) process.stderr.write(result.stderr);
1446
+ if (result.status === 0) return result;
1447
+ throw fatalSshStepError(result, failureMessage);
1448
+ }
1449
+
1450
+ /** The error a failed fatal ssh step throws, carrying its
1451
+ * {@link classifySshStepFailure} verdict as the flag abortTeardownDecision
1452
+ * reads. A transport failure names its cause in the message (the quota text
1453
+ * itself was echoed to stderr, not carried here); a remote failure keeps
1454
+ * `failureMessage` verbatim. Pure: result in, Error out. */
1455
+ function fatalSshStepError(result, failureMessage) {
1456
+ const cause = classifySshStepFailure(result);
1457
+ if (cause === "transport") {
1458
+ const why = isGcloudTransportFailure(`${result.stderr || ""}\n${result.stdout || ""}`)
1459
+ ? "an OS Login API quota 429 — too many concurrent fleet ssh calls"
1460
+ : "the ssh connection to the VM could not be established";
1461
+ const err = new Error(
1462
+ `${failureMessage}: gcloud failed before reaching the VM (${why}), after retrying. ` +
1463
+ "The remote command never ran; the VM itself was not shown to be broken.",
1464
+ );
1465
+ err.remoteCommandNeverRan = true;
1466
+ return err;
1467
+ }
1468
+ const err = new Error(failureMessage);
1469
+ if (cause === "unknown") err.sshCauseUnknown = true;
1470
+ return err;
1471
+ }
1472
+
1343
1473
  function ensureGcloud() {
1344
1474
  const result = spawnSync("gcloud", ["version"], { stdio: "ignore" });
1345
1475
  if (result.error || result.status !== 0) {
@@ -1579,6 +1709,7 @@ async function waitForIapReady(cfg) {
1579
1709
  const probeStderrs = [];
1580
1710
  while (Date.now() < deadline) {
1581
1711
  attempts++;
1712
+ // Tolerant ssh step: this loop is its own retry, so no wrapper.
1582
1713
  const probe = runGcloud(
1583
1714
  buildGcloudArgs("ssh-probe", cfg, { command: "true" }),
1584
1715
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -1836,6 +1967,7 @@ function sshScriptCommand(scriptBody, scriptArgs = [], envVars = {}) {
1836
1967
 
1837
1968
  async function ensureBootstrapped(cfg, { sleepFn } = {}) {
1838
1969
  console.error("Checking VM bootstrap state...");
1970
+ // Tolerant ssh step: a failed probe falls through to the idempotent bootstrap.
1839
1971
  const probe = runGcloud(
1840
1972
  buildGcloudArgs("ssh-probe", cfg, {
1841
1973
  command: "command -v docker >/dev/null && docker image inspect codeyam-editor:local >/dev/null 2>&1",
@@ -1946,6 +2078,7 @@ async function ensureBootstrapped(cfg, { sleepFn } = {}) {
1946
2078
  // single outcome string ("success" | "failed" | "running" | "absent"). Used
1947
2079
  // both for the idempotent re-attach decision and after each follow session.
1948
2080
  function probeBootstrapOutcome(cfg) {
2081
+ // Tolerant ssh step: unparseable output reads as "absent", which re-attaches.
1949
2082
  const r = runGcloud(
1950
2083
  buildGcloudArgs("ssh-probe", cfg, { command: bootstrapStatusCommand() }),
1951
2084
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -1956,6 +2089,7 @@ function probeBootstrapOutcome(cfg) {
1956
2089
  // Fetch the tail of the VM-side bootstrap log for an actionable failure
1957
2090
  // message — the operator sees WHY the build failed, not a bare exit code.
1958
2091
  function bootstrapLogTail(cfg, lines = 40) {
2092
+ // Tolerant ssh step: diagnostic only, falls back to "(log unavailable)".
1959
2093
  const r = runGcloud(
1960
2094
  buildGcloudArgs("ssh-probe", cfg, {
1961
2095
  command: `tail -n ${lines} ${BOOTSTRAP_LOG_PATH} 2>/dev/null`,
@@ -2179,6 +2313,7 @@ function decideProjectBootstrapFollowAction({ outcome, now, deadline } = {}) {
2179
2313
  // to a single outcome string ("success" | "failed" | "running" | "absent").
2180
2314
  // Used both for the idempotent re-attach decision and after each follow session.
2181
2315
  function probeProjectBootstrapOutcome(cfg, repoName) {
2316
+ // Tolerant ssh step: unparseable output reads as "absent", which re-attaches.
2182
2317
  const r = runGcloud(
2183
2318
  buildGcloudArgs("ssh-probe", cfg, { command: projectBootstrapStatusCommand(repoName) }),
2184
2319
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -2232,6 +2367,7 @@ function projectBootstrapEnv(cfg = {}, { env = process.env, githubToken, gitIden
2232
2367
  // Fetch the tail of the VM-side project-bootstrap log for an actionable failure
2233
2368
  // message — the operator sees WHY the install failed, not a bare exit code.
2234
2369
  function projectBootstrapLogTail(cfg, repoName, lines = 40) {
2370
+ // Tolerant ssh step: diagnostic only, falls back to "(log unavailable)".
2235
2371
  const r = runGcloud(
2236
2372
  buildGcloudArgs("ssh-probe", cfg, {
2237
2373
  command: `tail -n ${lines} ${projectBootstrapLogPath(repoName)} 2>/dev/null`,
@@ -2308,6 +2444,7 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2308
2444
  // correctly re-run (the re-run is idempotent — skip-on-exists per step). The
2309
2445
  // two structural checks stay as belt-and-suspenders for a legacy VM whose
2310
2446
  // marker predates this change but is otherwise wired.
2447
+ // Tolerant ssh step: a failed probe falls through to the idempotent bootstrap.
2311
2448
  const probe = runGcloud(
2312
2449
  buildGcloudArgs("ssh-probe", cfg, {
2313
2450
  command: projectBootstrapDoneProbeCommand(repoName),
@@ -2446,6 +2583,8 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2446
2583
  function probeEditorComposeState(cfg) {
2447
2584
  const repoName = resolveProject(cfg).name;
2448
2585
  const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2586
+ // Tolerant ssh step: a failure reports `inconclusive`, which every caller
2587
+ // treats as fail-safe.
2449
2588
  const probe = runGcloud(
2450
2589
  buildGcloudArgs("ssh-probe", cfg, {
2451
2590
  command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
@@ -2989,13 +3128,13 @@ async function seedGithubCredentials(cfg, opts = {}) {
2989
3128
  `echo ${b64} | base64 -d > "$t" && chmod 600 "$t" && ` +
2990
3129
  `if [ -s "$t" ]; then cat "$t" > "$f" && chmod 600 "$f" && rm -f "$t" && echo OK; ` +
2991
3130
  `else rm -f "$t"; echo EMPTY-WRITE-REFUSED >&2; exit 1; fi`;
2992
- const result = runGcloud(
2993
- buildGcloudArgs("ssh-probe", cfg, { command: remoteCmd }),
2994
- { stdio: ["ignore", "pipe", "inherit"] },
2995
- );
2996
- if (result.status !== 0) {
2997
- throw new Error("Failed to write ~/.codeyam-cloud-git-credentials on the VM");
2998
- }
3131
+ // Fatal ssh step — see runFatalSshStep. stdout stays unechoed, as it always
3132
+ // was: it carries only the `OK` marker.
3133
+ await runFatalSshStep(cfg, remoteCmd, {
3134
+ label: "write git credentials",
3135
+ failureMessage: "Failed to write ~/.codeyam-cloud-git-credentials on the VM",
3136
+ sleepFn: opts.sleepFn,
3137
+ });
2999
3138
  }
3000
3139
 
3001
3140
  /** Sentinel the remote read prints when it cannot find an `editor.json` under
@@ -3070,6 +3209,8 @@ async function readVmAppPort(cfg) {
3070
3209
  * the human description of the miss for the caller's own warning. */
3071
3210
  async function probeVmAppPort(cfg) {
3072
3211
  const repoName = resolveProject(cfg).name;
3212
+ // Tolerant ssh step: a failure is returned as `{ kind: "ssh" }` and the
3213
+ // caller falls back to VITE_PORT with a loud warning.
3073
3214
  const read = runGcloud(
3074
3215
  buildGcloudArgs("ssh-probe", cfg, {
3075
3216
  command: buildVmAppPortReadCommand(repoName),
@@ -3122,6 +3263,7 @@ const VM_PUBLISHED_PORT_MARKER = "<<<PUBLISHED>>>";
3122
3263
  * publishes (live `docker ps` bindings) in ONE SSH round-trip, so the roster
3123
3264
  * pass costs one extra probe per VM. Either side is null when unreadable. */
3124
3265
  function readVmAppPortsForDrift(cfg) {
3266
+ // Tolerant ssh step: a failure reads as "unknown" (nulls), never as drift.
3125
3267
  const read = runGcloud(
3126
3268
  buildGcloudArgs("ssh-probe", cfg, {
3127
3269
  command:
@@ -3154,6 +3296,7 @@ function parseVmAppPortsForDrift(stdout) {
3154
3296
  * Unknown — including a VM whose checkout predates the script — is null, and
3155
3297
  * null is never treated as idle. */
3156
3298
  function readVmIdleVerdict(cfg) {
3299
+ // Tolerant ssh step: a failure returns null (no verdict).
3157
3300
  const read = runGcloud(
3158
3301
  buildGcloudArgs("ssh-probe", cfg, {
3159
3302
  command:
@@ -3300,7 +3443,7 @@ async function seedPreviewOrigin(
3300
3443
  // One round-trip reads both the current editor.local.json (for the
3301
3444
  // skip-on-already-set guard) and stack.json (for the sameOriginSafe flag that
3302
3445
  // decides same-origin subpath vs dedicated origin). Mirrors readVmAppPort's
3303
- // separator-delimited cat.
3446
+ // separator-delimited cat. Tolerant ssh step: a failed read parses as `{}`.
3304
3447
  const read = runGcloud(
3305
3448
  buildGcloudArgs("ssh-probe", cfg, {
3306
3449
  command:
@@ -3406,15 +3549,12 @@ async function seedPreviewOrigin(
3406
3549
  const merged = deepMergeJson(current, overlay);
3407
3550
  const mergedJson = JSON.stringify(merged, null, 2);
3408
3551
  const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
3409
- const write = runGcloud(
3410
- buildGcloudArgs("ssh-probe", cfg, {
3411
- command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
3412
- }),
3413
- { stdio: ["ignore", "inherit", "inherit"] },
3414
- );
3415
- if (write.status !== 0) {
3416
- throw new Error(`Failed to write ${remotePath} on VM`);
3417
- }
3552
+ // Fatal ssh step — see runFatalSshStep.
3553
+ await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
3554
+ label: "seed editor.local.json (previewOrigin)",
3555
+ failureMessage: `Failed to write ${remotePath} on VM`,
3556
+ echoStdout: true,
3557
+ });
3418
3558
  }
3419
3559
 
3420
3560
  /** READY-probe auto-heal: after the editor container is verifiably Up, ask the
@@ -3448,6 +3588,8 @@ async function seedPreviewOrigin(
3448
3588
  * `cmdReconcileIngress`, the ~15-minute roster sweep that genuinely does run,
3449
3589
  * which is what makes a mid-life flip self-heal without an Add. */
3450
3590
  async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPort = null } = {}) {
3591
+ // Tolerant ssh steps (this probe and the reload below): the heal is
3592
+ // best-effort and never fails the add.
3451
3593
  const probe = runGcloud(
3452
3594
  buildGcloudArgs("ssh-probe", cfg, {
3453
3595
  command: `curl -fsS --max-time 5 http://localhost:${EDITOR_PORT}/api/config 2>/dev/null || echo '{}'`,
@@ -3516,8 +3658,9 @@ async function healPreviewOriginAtReady(cfg, appPort = VITE_PORT, { resolveAppPo
3516
3658
  * the provider seed MUST run before ensureEditorContainerUp, because the editor
3517
3659
  * caches config.provider at startup and reports it as currentProvider; a post-start
3518
3660
  * seed leaves the running editor (and therefore the dashboard badge) on the default
3519
- * "claude" until a config reload. */
3520
- async function seedAgentApiKey(cfg) {
3661
+ * "claude" until a config reload. `sleepFn` is the retry backoff seam, injectable
3662
+ * as on ensureBootstrapped. */
3663
+ async function seedAgentApiKey(cfg, { sleepFn } = {}) {
3521
3664
  // The provider seed is unconditional — `buildEditorLocalEnvPatch` always
3522
3665
  // returns a patch carrying `provider`, so the chosen agent always lands on
3523
3666
  // the VM. The API-key seed is opportunistic: when the matching key isn't set
@@ -3544,6 +3687,8 @@ async function seedAgentApiKey(cfg) {
3544
3687
  : `Seeding provider '${cfg.agent}' into ${remotePath}...`;
3545
3688
  console.error(seedLabel);
3546
3689
 
3690
+ // Tolerant ssh step: a failed read falls back to `{}` below, so it needs no
3691
+ // retry wrapper. The write it feeds is the fatal one.
3547
3692
  const read = runGcloud(
3548
3693
  buildGcloudArgs("ssh-probe", cfg, {
3549
3694
  command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
@@ -3566,15 +3711,15 @@ async function seedAgentApiKey(cfg) {
3566
3711
  const merged = deepMergeJson(current, patch);
3567
3712
  const mergedJson = JSON.stringify(merged, null, 2);
3568
3713
  const b64 = Buffer.from(mergedJson, "utf8").toString("base64");
3569
- const write = runGcloud(
3570
- buildGcloudArgs("ssh-probe", cfg, {
3571
- command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
3572
- }),
3573
- { stdio: ["ignore", "inherit", "inherit"] },
3574
- );
3575
- if (write.status !== 0) {
3576
- throw new Error(`Failed to write ${remotePath} on VM`);
3577
- }
3714
+ // Fatal ssh step — see runFatalSshStep. This exact write, then a bare
3715
+ // `stdio: "inherit"` runGcloud, is the step whose oslogin 429 deleted VM-37 on
3716
+ // 2026-10-08: no retry, and the quota text never reached the thrown message.
3717
+ await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
3718
+ label: "seed editor.local.json (provider)",
3719
+ failureMessage: `Failed to write ${remotePath} on VM`,
3720
+ echoStdout: true,
3721
+ sleepFn,
3722
+ });
3578
3723
  // NOTE: seedAgentApiKey deliberately does NOT install the build-agent CLI.
3579
3724
  // The CLI install is a POST-container-start step owned solely by
3580
3725
  // ensureEditorContainerUp (which runs `docker compose up -d` first). Seeding
@@ -3627,6 +3772,7 @@ async function seedGreenfieldProjectTitle(cfg) {
3627
3772
  const remotePath = `~/projects/${repoName}/.codeyam/editor.local.json`;
3628
3773
  console.error(`Seeding projectTitle='${patch.projectTitle}' into ${remotePath}...`);
3629
3774
 
3775
+ // Tolerant ssh step: mergeEditorLocalPatchB64 treats a failed read as `{}`.
3630
3776
  const read = runGcloud(
3631
3777
  buildGcloudArgs("ssh-probe", cfg, {
3632
3778
  command: `cat ${remotePath} 2>/dev/null || echo '{}'`,
@@ -3634,15 +3780,12 @@ async function seedGreenfieldProjectTitle(cfg) {
3634
3780
  { stdio: ["ignore", "pipe", "ignore"] },
3635
3781
  );
3636
3782
  const b64 = mergeEditorLocalPatchB64(read.stdout, patch);
3637
- const write = runGcloud(
3638
- buildGcloudArgs("ssh-probe", cfg, {
3639
- command: buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }),
3640
- }),
3641
- { stdio: ["ignore", "inherit", "inherit"] },
3642
- );
3643
- if (write.status !== 0) {
3644
- throw new Error(`Failed to write ${remotePath} on VM`);
3645
- }
3783
+ // Fatal ssh step — see runFatalSshStep.
3784
+ await runFatalSshStep(cfg, buildEditorLocalSeedWriteCommand({ repoName, remotePath, b64 }), {
3785
+ label: "seed editor.local.json (projectTitle)",
3786
+ failureMessage: `Failed to write ${remotePath} on VM`,
3787
+ echoStdout: true,
3788
+ });
3646
3789
  }
3647
3790
 
3648
3791
  /** Tri-state probe of the editor container's running state on the VM host.
@@ -3663,6 +3806,7 @@ async function seedGreenfieldProjectTitle(cfg) {
3663
3806
  * which — pre-fix — made every probe report a non-running container regardless
3664
3807
  * of its real state and took the whole fleet to zero on 2026-06-30. */
3665
3808
  function probeContainerState(cfg, container) {
3809
+ // Tolerant ssh step: a failure is `"unknown"`, which callers do not count as down.
3666
3810
  const checkProbe = runGcloud(
3667
3811
  buildGcloudArgs("ssh-probe", cfg, {
3668
3812
  command: `docker ps --filter name=^/${container}$ --filter status=running --format '{{.Names}}'`,
@@ -3685,6 +3829,8 @@ function probeContainerRunning(cfg, container) {
3685
3829
 
3686
3830
  /** Probe whether `binary` resolves on PATH inside the running container. */
3687
3831
  function probeAgentBinaryInstalled(cfg, container, binary) {
3832
+ // Tolerant ssh step: a failure reads as "not installed", which leads to the
3833
+ // idempotent install (a fatal step) rather than a throw of its own.
3688
3834
  const probe = runGcloud(
3689
3835
  buildGcloudArgs("ssh-probe", cfg, {
3690
3836
  command: `docker exec ${container} bash -lc 'command -v ${binary}'`,
@@ -3835,16 +3981,13 @@ async function ensureAgentCliInstalled(cfg, waitOpts = {}) {
3835
3981
 
3836
3982
  const hostCommand = `docker exec -i ${container} bash -s <<'EOF'\n${containerScript}\nEOF`;
3837
3983
 
3838
- const installCmd = runGcloud(
3839
- buildGcloudArgs("ssh-probe", cfg, {
3840
- command: hostCommand,
3841
- }),
3842
- { stdio: ["ignore", "inherit", "inherit"] }
3843
- );
3844
-
3845
- if (installCmd.status !== 0) {
3846
- throw new Error(`Failed to install build-agent CLI in container for agent: ${agent}`);
3847
- }
3984
+ // Fatal ssh step — see runFatalSshStep. Safe to retry: the script short-circuits
3985
+ // when the binary is already on PATH.
3986
+ await runFatalSshStep(cfg, hostCommand, {
3987
+ label: "install build-agent CLI",
3988
+ failureMessage: `Failed to install build-agent CLI in container for agent: ${agent}`,
3989
+ echoStdout: true,
3990
+ });
3848
3991
  }
3849
3992
 
3850
3993
  async function openTunnel(cfg, appPort = VITE_PORT) {
@@ -4966,27 +5109,15 @@ async function cmdUp(args, projectDir = rootDir) {
4966
5109
  // `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
4967
5110
  // VM is in right now, then let the pure decision rule — see
4968
5111
  // abortTeardownDecision's doc comment for why each input is load-bearing.
4969
- const state = probeAbortTeardownState(cfg, createdThisRun);
4970
- // An error that never reached the VM proves nothing about it — see the
4971
- // `remoteCommandNeverRan` paragraph of abortTeardownDecision.
4972
- const remoteCommandNeverRan =
4973
- Boolean(err && err.remoteCommandNeverRan) || isGcloudTransportFailure(err && err.message);
4974
- const verdict = abortTeardownDecision({ createdThisRun, ...state, remoteCommandNeverRan });
5112
+ const state = abortEvidence(err, probeAbortTeardownState(cfg, createdThisRun));
5113
+ const verdict = abortTeardownDecision({ createdThisRun, ...state });
4975
5114
  if (createdThisRun && verdict === "keep") {
4976
- // Deliberately loud and specific. The old blanket "tearing down the VM
4977
- // cloud:up created this run (no orphan left behind)" was actively
4978
- // misleading here — it asserted no orphan while deleting a working VM.
4979
- const why = remoteCommandNeverRan
4980
- ? "the failing step never reached the VM (a gcloud transport/quota error), so nothing shows the VM is broken"
4981
- : state.probeInconclusive
4982
- ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4983
- : state.containerUp
4984
- ? "its editor container is RUNNING — another run brought it up"
4985
- : "it is in the fleet roster and visible to the dashboard";
4986
5115
  console.error(
4987
- `✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
4988
- ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
4989
- ` If it really should go, remove it deliberately: npm run cloud:destroy`,
5116
+ abortKeptMessage({
5117
+ instanceName: cfg.instanceName,
5118
+ vmNumber: resolveFleetVmNumber(cfg.instanceName),
5119
+ state,
5120
+ }),
4990
5121
  );
4991
5122
  }
4992
5123
  if (verdict === "delete") {
@@ -5161,6 +5292,7 @@ async function cmdStatus(args, projectDir = rootDir) {
5161
5292
  return;
5162
5293
  }
5163
5294
 
5295
+ // Tolerant ssh step: status display only; a failure prints "?".
5164
5296
  const imgProbe = runGcloud(
5165
5297
  buildGcloudArgs("ssh-probe", cfg, {
5166
5298
  command:
@@ -5698,12 +5830,12 @@ async function probeGitCredentials(cfg) {
5698
5830
  `${projectCd} && if ${composeExec} git -C /workspace ls-remote origin -h HEAD ` +
5699
5831
  `>/dev/null 2>/tmp/cy-lsr.err; then echo LSREMOTE=ok; ` +
5700
5832
  `else echo LSREMOTE=fail; sed 's/^/LSRERR:/' /tmp/cy-lsr.err; fi`;
5701
- const probe = runGcloud(
5702
- buildGcloudArgs("ssh-probe", cfg, { command: probeCmd }),
5703
- { stdio: ["ignore", "pipe", "ignore"] },
5704
- );
5705
- if (probe.status !== 0 || typeof probe.stdout !== "string") {
5706
- throw new Error(`Failed to probe credentials on ${cfg.instanceName} (SSH error).`);
5833
+ // Fatal ssh step — see runFatalSshStep. It runs on the add path too, via
5834
+ // assertGitPushCredentials. stdout is parsed below, so it is not echoed.
5835
+ const failureMessage = `Failed to probe credentials on ${cfg.instanceName} (SSH error).`;
5836
+ const probe = await runFatalSshStep(cfg, probeCmd, { label: "probe git credentials", failureMessage });
5837
+ if (typeof probe.stdout !== "string") {
5838
+ throw new Error(failureMessage);
5707
5839
  }
5708
5840
  const out = probe.stdout;
5709
5841
  return {
@@ -5747,14 +5879,12 @@ async function seedLiveCredentialCache(cfg, token) {
5747
5879
  `echo ${storeB64} | base64 -d | ${composeExec} ` +
5748
5880
  `bash -c 'cat > ${store} && chmod 600 ${store}' && ` +
5749
5881
  `echo APPROVED`;
5750
- const seed = runGcloud(
5751
- buildGcloudArgs("ssh-probe", cfg, { command: seedCmd }),
5752
- { stdio: ["ignore", "pipe", "inherit"] },
5753
- );
5754
- if (seed.status !== 0 || !/APPROVED/.test(String(seed.stdout || ""))) {
5755
- throw new Error(
5756
- `Failed to seed the live credential cache on ${cfg.instanceName}.`,
5757
- );
5882
+ // Fatal ssh step — see runFatalSshStep. Safe to retry: `credential approve`
5883
+ // and the in-place store truncate are both idempotent.
5884
+ const failureMessage = `Failed to seed the live credential cache on ${cfg.instanceName}.`;
5885
+ const seed = await runFatalSshStep(cfg, seedCmd, { label: "seed live credential cache", failureMessage });
5886
+ if (!/APPROVED/.test(String(seed.stdout || ""))) {
5887
+ throw new Error(failureMessage);
5758
5888
  }
5759
5889
  }
5760
5890
 
@@ -5804,15 +5934,14 @@ async function cmdReconcileAdmin(args, projectDir = rootDir) {
5804
5934
  console.error(
5805
5935
  `Reconciling admin mode to ${desired === "1" ? "ON" : "off"} on ${cfg.instanceName}...`,
5806
5936
  );
5807
- const result = runGcloud(
5808
- buildGcloudArgs("ssh-probe", cfg, {
5809
- command: buildReconcileAdminCommand(repoName, desired),
5810
- }),
5811
- { stdio: "inherit" },
5812
- );
5813
- if (result.status !== 0) {
5814
- throw new Error(`Failed to reconcile admin mode on ${cfg.instanceName}.`);
5815
- }
5937
+ // Fatal ssh step — see runFatalSshStep. No teardown follows here, but a 429 on
5938
+ // an operator command is still a retry, not a failure. Safe to retry: the
5939
+ // compose line rewrite and `up -d editor` are both idempotent.
5940
+ await runFatalSshStep(cfg, buildReconcileAdminCommand(repoName, desired), {
5941
+ label: "reconcile admin mode",
5942
+ failureMessage: `Failed to reconcile admin mode on ${cfg.instanceName}.`,
5943
+ echoStdout: true,
5944
+ });
5816
5945
  console.error(
5817
5946
  ` ✓ editor service recreated; /api/config now reports admin=${desired === "1" ? "true" : "false"}.`,
5818
5947
  );
@@ -5990,6 +6119,8 @@ module.exports = {
5990
6119
  probeContainerState,
5991
6120
  waitForEditorComposeRunning,
5992
6121
  abortTeardownDecision,
6122
+ abortEvidence,
6123
+ abortKeptMessage,
5993
6124
  probeAbortTeardownState,
5994
6125
  probeAgentBinaryInstalled,
5995
6126
  waitForContainerRunning,
@@ -6080,6 +6211,9 @@ module.exports = {
6080
6211
  formatGcloudFailure,
6081
6212
  isTransientSshFailure,
6082
6213
  runGcloudSshWithRetry,
6214
+ classifySshStepFailure,
6215
+ runFatalSshStep,
6216
+ fatalSshStepError,
6083
6217
  ensureInstance,
6084
6218
  ensureBootstrapped,
6085
6219
  assertEditorContainerRunning,
@@ -25,15 +25,28 @@
25
25
  * dashboard's own monitoring gathers (bounded in concurrency by hostOpLimiter,
26
26
  * not in rate) and the tunnel watchdog.
27
27
  * - `perAddCallsPerMin` — what one in-flight add is charged. Deliberately its
28
- * PEAK, not its average: the fast first minute of the container gate (6 reads)
29
- * overlapping cloud:up's own ssh steps or the post-roster burst. Charging the
30
- * peak is wrong in the safe direction — it queues a little early rather than
31
- * 429ing a provision.
28
+ * PEAK, not its average. Charging the peak is wrong in the safe direction — it
29
+ * queues a little early rather than 429ing a provision.
32
30
  *
33
- * At these defaults (60 - 20) / 10 = 4 concurrent adds, the same as the job cap,
34
- * so an operator who raises MAX_JOB_CONCURRENCY meets this budget instead of the
35
- * quota. */
36
- const API_BUDGET_DEFAULTS = Object.freeze({ budgetPerMin: 60, reservedPerMin: 20, perAddCallsPerMin: 10 });
31
+ * Provenance of 15 (2026-10-09, re-derived after VM-37 429'd on 2026-10-08
32
+ * under the old 10): the peak is cloud:up's seed → container-gate window,
33
+ * where each `gcloud compute ssh` (≥1 loginProfile read) runs back to back.
34
+ * Counted from npm/cloud.js on the clone path: app-port probe 1, previewOrigin
35
+ * read+write 2, git-credentials write 1, provider seed read+write 2, compose
36
+ * state probe + `up -d` 2, container probe + CLI install 2, git-auth probe 1,
37
+ * final container assert 1, CLI probe 1 = 13; a greenfield add adds the
38
+ * projectTitle read+write = 15. At ~4s per IAP ssh round trip that is ~15
39
+ * reads inside one minute. The old 10 was the container gate's first minute
40
+ * (6 reads) plus overlap — it did not count the seed burst VM-37 died in.
41
+ * This is a STATIC count, not a measured counter; replace it if a measured
42
+ * per-add rate is ever taken. The batch `dash_add` path is NOT the gap:
43
+ * server.js admits each reserved VM separately through tryStartOrQueue, and
44
+ * addVm reaches startJob synchronously, so the job cap holds per VM.
45
+ *
46
+ * At these defaults an add is admitted while 20 + 15·inFlight + 15 ≤ 60, i.e.
47
+ * 2 concurrent adds — below the job cap of 4, so this budget, not the cap, is
48
+ * what paces a batch. API_CALLS_PER_ADD_PER_MIN overrides it on the dashboard. */
49
+ const API_BUDGET_DEFAULTS = Object.freeze({ budgetPerMin: 60, reservedPerMin: 20, perAddCallsPerMin: 15 });
37
50
 
38
51
  // A positive finite number, else 0 — so garbage degrades toward admitting work,
39
52
  // never toward a phantom load that would freeze admission forever.
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ // npm/fleet-dev-server-health.js — pure classifier for the fleet dashboard's
4
+ // "dev server is crash-looping" surface.
5
+ //
6
+ // A VM whose app dev server crash-looped read as an idle, healthy card: on
7
+ // 2026-10-10 the freshly provisioned cadyam VMs 3 and 4 showed
8
+ // `previewServing: false` with `degradedReasons: []`, and the only way to learn
9
+ // why the Live Preview was dead was to SSH in and read dev-server.log. This
10
+ // module turns the editor's session-info `devServer` object into a reason that
11
+ // names the failure and its last error line.
12
+ //
13
+ // `previewServing: false` alone is NOT a fault: a cold first build (trunk/wasm)
14
+ // keeps the dev server unready for minutes. So a reason is raised only on
15
+ // positive evidence of failure — the editor's restart breaker has tripped, or
16
+ // the restart counter has climbed by RESTART_CLIMB_THRESHOLD within
17
+ // RESTART_WINDOW_MS. The counter is cumulative over the editor's lifetime, so a
18
+ // raw total would flag a VM forever after a few restarts last week; the climb
19
+ // is measured across this dashboard's own poll samples instead.
20
+ //
21
+ // Like fleet-datastore-health.js, all decisions live here and unit-test without
22
+ // a VM; server.js stays the thin I/O shell.
23
+
24
+ const RESTART_WINDOW_MS = 5 * 60 * 1000;
25
+ const RESTART_CLIMB_THRESHOLD = 3;
26
+
27
+ // Append this poll's restart counter to the sample history and drop samples
28
+ // older than the window. A missing/older-editor `devServer` leaves the history
29
+ // unchanged, so absence of the field never manufactures a climb.
30
+ function nextRestartSamples(prevSamples, devServer, nowMs, windowMs = RESTART_WINDOW_MS) {
31
+ const prev = Array.isArray(prevSamples) ? prevSamples : [];
32
+ const kept = prev.filter((s) => s && nowMs - s.at <= windowMs);
33
+ const count = devServer && Number.isFinite(devServer.restartCount) ? devServer.restartCount : null;
34
+ if (count === null) return kept;
35
+ return [...kept, { at: nowMs, count }];
36
+ }
37
+
38
+ // Total restarts observed across the samples. Only positive steps count: the
39
+ // counter drops to 0 when the editor itself restarts, and that drop is not a
40
+ // dev-server restart.
41
+ function restartClimb(samples) {
42
+ const list = Array.isArray(samples) ? samples : [];
43
+ let climb = 0;
44
+ for (let i = 1; i < list.length; i += 1) {
45
+ const delta = list[i].count - list[i - 1].count;
46
+ if (delta > 0) climb += delta;
47
+ }
48
+ return climb;
49
+ }
50
+
51
+ // The single source for the `dev-server-crash-loop` reason string.
52
+ function devServerCrashLoopReason({ failed, failureCount, climb, lastErrorLine }) {
53
+ const what = failed
54
+ ? `dev server stopped after ${failureCount || 'repeated'} consecutive failures`
55
+ : `dev server restarted ${climb} times in the last ${Math.round(RESTART_WINDOW_MS / 60000)} minutes`;
56
+ const why = lastErrorLine ? lastErrorLine : 'no error line in dev-server.log';
57
+ return `dev-server-crash-loop: ${why} (${what}).`;
58
+ }
59
+
60
+ // Decide whether the card raises a dev-server reason. `devServer` is the
61
+ // session-info object (null/undefined for an editor that predates it — which
62
+ // raises nothing); `samples` is the history from nextRestartSamples.
63
+ function classifyDevServerHealth({ devServer, samples } = {}) {
64
+ if (!devServer || typeof devServer !== 'object') return { crashLooping: false, reasons: [] };
65
+ const failed = devServer.failed === true;
66
+ const climb = restartClimb(samples);
67
+ if (!failed && climb < RESTART_CLIMB_THRESHOLD) return { crashLooping: false, reasons: [] };
68
+ return {
69
+ crashLooping: true,
70
+ reasons: [devServerCrashLoopReason({
71
+ failed,
72
+ failureCount: devServer.failureCount,
73
+ climb,
74
+ lastErrorLine: devServer.lastErrorLine || null,
75
+ })],
76
+ };
77
+ }
78
+
79
+ module.exports = {
80
+ RESTART_WINDOW_MS,
81
+ RESTART_CLIMB_THRESHOLD,
82
+ nextRestartSamples,
83
+ restartClimb,
84
+ devServerCrashLoopReason,
85
+ classifyDevServerHealth,
86
+ };