@codeyam-editor/codeyam-editor 0.1.12 → 0.1.13-staging.g3c6e0c9

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.
@@ -0,0 +1,169 @@
1
+ 'use strict';
2
+
3
+ // Detect a fleet VM whose app port drifted away from the port its container
4
+ // publishes on the host.
5
+ //
6
+ // A VM's compose render publishes the app port resolved ONCE at bootstrap
7
+ // (`apps[0].port ?? port ?? 5173`). When the project later moves its app port
8
+ // in-repo, the editor's preview proxy follows the new port while the host keeps
9
+ // publishing the old one: docker-proxy accepts the load balancer's connection
10
+ // and immediately resets it, and every in-container probe reads green because
11
+ // the editor probes its own proxy on the new port. The only comparison that can
12
+ // see it is DECLARED (editor.json + editor.local.json) against PUBLISHED (what
13
+ // the running container actually maps on the host) — that is what this module
14
+ // does. It never repairs: changing the publication means recreating the
15
+ // container, which drops every live agent session.
16
+ //
17
+ // Pure helpers plus an injectable collector, so the reconcile and `vm-ingress.sh
18
+ // status` share one rule and the unit tests never reach for SSH.
19
+
20
+ /** The editor's control port. Pinned by contract, so on a VM's editor container
21
+ * the app surface is the OTHER published mapping — the same disambiguation
22
+ * `rcc_recover_ports` (scripts/lib/render-cloud-compose.sh) makes. */
23
+ const EDITOR_CONTROL_PORT = 14199;
24
+
25
+ const PORT_DRIFT_RUNBOOK = "docs/preview-port-drift.md";
26
+
27
+ function isValidPort(n) {
28
+ return Number.isInteger(n) && n > 0 && n < 65536;
29
+ }
30
+
31
+ /** Remote command that prints the editor container's published port list
32
+ * (`docker ps` `{{.Ports}}`), selecting the container by the control port it
33
+ * publishes rather than by a name, so a client VM whose compose project is not
34
+ * named `codeyam-editor` resolves the same way. Reads the LIVE bindings — no
35
+ * compose-file path to guess. Pure string builder. */
36
+ function buildPublishedAppPortReadCommand() {
37
+ return `docker ps --filter publish=${EDITOR_CONTROL_PORT} --format '{{.Ports}}' 2>/dev/null`;
38
+ }
39
+
40
+ /** Parse that command's output into the published app port, or null.
41
+ *
42
+ * `{{.Ports}}` reads like `0.0.0.0:3000->3000/tcp, [::]:3000->3000/tcp,
43
+ * 10.0.0.5:14199->14199/tcp`. The app port is the single host port that is not
44
+ * the control port. Anything else — no container, more than one container, no
45
+ * control-port mapping, zero or several candidate app ports — returns null:
46
+ * an unreadable publication must never be reported as a mismatch. */
47
+ function parsePublishedAppPort(stdout) {
48
+ if (typeof stdout !== "string") return null;
49
+ const lines = stdout
50
+ .split("\n")
51
+ .map((l) => l.trim())
52
+ .filter(Boolean);
53
+ if (lines.length !== 1) return null;
54
+ const hostPorts = new Set();
55
+ for (const m of lines[0].matchAll(/:(\d+)->\d+\/tcp/g)) hostPorts.add(Number(m[1]));
56
+ if (!hostPorts.delete(EDITOR_CONTROL_PORT)) return null;
57
+ if (hostPorts.size !== 1) return null;
58
+ const [port] = hostPorts;
59
+ return isValidPort(port) ? port : null;
60
+ }
61
+
62
+ /** Parse `cloud-session-idle.sh` stdout into `idle` | `active` | null. Null
63
+ * (unknown) is deliberately NOT idle — the same asymmetry that script and
64
+ * `converge-vm.sh --converge-env` honour. */
65
+ function parseIdleVerdict(stdout) {
66
+ if (typeof stdout !== "string") return null;
67
+ const v = stdout.trim().split("\n").pop();
68
+ return v === "idle" || v === "active" ? v : null;
69
+ }
70
+
71
+ /** The remediations that apply to one drifted VM, cheapest first.
72
+ *
73
+ * The session-preserving pin always applies. The re-publish (recreate the
74
+ * container so the host publishes the declared port) is named ONLY when the
75
+ * VM is known idle, because it drops the PTY broker and every live session. */
76
+ function appPortDriftRemediations({ vmNumber, declared, published, idle }) {
77
+ const out = [
78
+ {
79
+ kind: "pin-to-published",
80
+ preservesSessions: true,
81
+ text:
82
+ `on VM-${vmNumber}, run \`codeyam-editor editor config-override apps.0.port ${published}\` ` +
83
+ `in the project — pins the app back to the published port with an in-place proxy rebind, no restart`,
84
+ },
85
+ ];
86
+ if (idle === "idle") {
87
+ out.push({
88
+ kind: "republish-declared",
89
+ preservesSessions: false,
90
+ text:
91
+ `VM-${vmNumber} is idle, so the container can instead be recreated to publish ${declared}: ` +
92
+ `change the app-port mapping in its docker-compose.cloud.yml from ${published} to ${declared} ` +
93
+ `and recreate the editor container (see ${PORT_DRIFT_RUNBOOK})`,
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /** Compare one VM's declared app port against its published host port.
100
+ *
101
+ * `deps.readDeclaredPort` / `deps.readPublishedPort` return a port or null;
102
+ * `deps.readIdleVerdict` (optional) returns `idle` | `active` | null and is
103
+ * only consulted once a drift is found. Returns `[]` when the two agree or
104
+ * when EITHER cannot be read — no 5173 fallback is ever used for the
105
+ * comparison — else a one-element findings array. The published read is
106
+ * skipped when the declared port is unreadable, so a VM with no scaffolded
107
+ * project costs one probe, not two. */
108
+ async function collectAppPortDrift(vm, deps) {
109
+ const { vmNumber, slug = null } = vm || {};
110
+ const declared = await deps.readDeclaredPort();
111
+ if (!isValidPort(declared)) return [];
112
+ const published = await deps.readPublishedPort();
113
+ if (!isValidPort(published) || published === declared) return [];
114
+ const idle = deps.readIdleVerdict ? await deps.readIdleVerdict() : null;
115
+ return [
116
+ {
117
+ vmNumber,
118
+ slug,
119
+ declared,
120
+ published,
121
+ idle,
122
+ remediations: appPortDriftRemediations({ vmNumber, declared, published, idle }),
123
+ },
124
+ ];
125
+ }
126
+
127
+ /** Render one finding as the operator-facing warning line(s). */
128
+ function formatAppPortDriftWarning(finding) {
129
+ const label = finding.slug || `vm-${finding.vmNumber}`;
130
+ const lines = [
131
+ ` VM-${finding.vmNumber} ${label}: PORT DRIFT — the project declares app port ` +
132
+ `${finding.declared} but the container publishes ${finding.published}, so the ` +
133
+ `load balancer cannot reach the Live Preview (in-container probes still read healthy).`,
134
+ ];
135
+ for (const r of finding.remediations) lines.push(` fix: ${r.text}`);
136
+ if (finding.idle !== "idle") {
137
+ lines.push(
138
+ ` (recreating the container to publish ${finding.declared} is not offered: ` +
139
+ `the VM is ${finding.idle || "not confirmed idle"} and a recreate drops live sessions)`,
140
+ );
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ /** Render the single machine-readable field `vm-ingress.sh status` prints.
146
+ * Kept separate from the `verdict=` line on purpose: the verdict drives the
147
+ * reconcile's re-`up` loop, and re-`up`ping cannot fix a port-publication
148
+ * drift. */
149
+ function formatPortDriftStatusLine(findings, { declared = null, published = null } = {}) {
150
+ if (findings.length) {
151
+ const f = findings[0];
152
+ return `port-drift=yes declared=${f.declared} published=${f.published}`;
153
+ }
154
+ if (isValidPort(declared) && isValidPort(published)) {
155
+ return `port-drift=no declared=${declared} published=${published}`;
156
+ }
157
+ return "port-drift=unknown";
158
+ }
159
+
160
+ module.exports = {
161
+ EDITOR_CONTROL_PORT,
162
+ buildPublishedAppPortReadCommand,
163
+ parsePublishedAppPort,
164
+ parseIdleVerdict,
165
+ appPortDriftRemediations,
166
+ collectAppPortDrift,
167
+ formatAppPortDriftWarning,
168
+ formatPortDriftStatusLine,
169
+ };
package/npm/cloud.js CHANGED
@@ -105,6 +105,17 @@ const {
105
105
  firewallNeedsConverge,
106
106
  firewallAdmitsPort,
107
107
  } = require("./ingress-invoke");
108
+ // Declared-vs-published app-port comparison (pure; unit-tested in
109
+ // app-port-drift.test.js). The SSH readers that feed it live below, beside
110
+ // readVmAppPort.
111
+ const {
112
+ buildPublishedAppPortReadCommand,
113
+ parsePublishedAppPort,
114
+ parseIdleVerdict,
115
+ collectAppPortDrift,
116
+ formatAppPortDriftWarning,
117
+ formatPortDriftStatusLine,
118
+ } = require("./app-port-drift");
108
119
  // Shared fleet-roster writer/reader. `cloud:up` rosters the VM it just
109
120
  // provisioned so the dashboard, the cloudflared-monitor tunnel publisher, and
110
121
  // the watchdog see it within one poll cycle — without a manual fleet-vms.json
@@ -114,7 +125,7 @@ const { addToRoster, readRoster } = require("./fleet-roster");
114
125
  // Tells a docker container-name conflict (a concurrent run already started this
115
126
  // container) apart from a real `docker compose up -d` failure, so the abort path
116
127
  // can converge instead of escalating to instance deletion.
117
- const { classifyComposeUpFailure } = require("./compose-conflict");
128
+ const { classifyComposeUpFailure, isGcloudTransportFailure } = require("./compose-conflict");
118
129
  // The two streamed bootstrap scripts, named once for this file's STREAMED_SCRIPTS
119
130
  // and for the advance's provisioning pre-flight, which checks the same set.
120
131
  const { BOOTSTRAP_SCRIPT, PROJECT_BOOTSTRAP_SCRIPT } = require("./provisioning-contract");
@@ -881,8 +892,8 @@ function parseEditorComposeState(stdout) {
881
892
  }
882
893
 
883
894
  /** Decide whether `cloud:up`'s abort handler may delete the instance it is
884
- * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
885
- * → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
895
+ * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive,
896
+ * remoteCommandNeverRan } → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
886
897
  * `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
887
898
  * exhaustively testable without a VM.
888
899
  *
@@ -904,9 +915,20 @@ function parseEditorComposeState(stdout) {
904
915
  * SAFE: a failing probe is how we reach the abort handler in the first place,
905
916
  * so it can never count as evidence the VM is dead. An orphan costs one
906
917
  * `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
907
- * any unpushed work on the VM. */
908
- function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
918
+ * any unpushed work on the VM.
919
+ * - `remoteCommandNeverRan` — the error that brought us here was a transport
920
+ * failure: the gcloud client never reached the VM (an OS Login quota 429 on
921
+ * 2026-10-01), so the step it was running never executed. Same class as
922
+ * `probeInconclusive` — absence of evidence — and it must fail SAFE the same
923
+ * way. On that date the probes truthfully reported "container down, not
924
+ * rostered", but only BECAUSE compose-up never ran; deleting on that reading
925
+ * destroyed VM-8 after ~19 minutes of finished build. A run that never managed
926
+ * to execute its step has learned nothing about the VM, so it has not earned
927
+ * the right to delete one. Defense in depth: the compose-up retry should keep
928
+ * this from being reached at all. */
929
+ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive, remoteCommandNeverRan } = {}) {
909
930
  if (!createdThisRun) return "keep";
931
+ if (remoteCommandNeverRan) return "keep";
910
932
  if (probeInconclusive) return "keep";
911
933
  if (containerUp) return "keep";
912
934
  if (rostered) return "keep";
@@ -1268,13 +1290,24 @@ function provisionFailureMessage(verb, instanceName, result) {
1268
1290
  );
1269
1291
  }
1270
1292
 
1271
- // A bounded, pure classifier that keys on status === 255 (gcloud-ssh's exit
1272
- // for an ssh failure) and a signature match against known transient strings.
1293
+ // A bounded, pure classifier with two independent branches:
1294
+ // - status === 255 (gcloud-ssh's exit for an ssh failure) plus a signature
1295
+ // match against known transient SSH-transport strings;
1296
+ // - a gcloud CLIENT-SIDE API failure (an OS Login quota 429), at any status.
1297
+ // gcloud does not exit 255 for these, so the status code is evidence for the
1298
+ // first branch, never a precondition for reading the text — gating on it is
1299
+ // what kept the 2026-10-01 VM-8 quota 429 out of the retry path.
1273
1300
  function isTransientSshFailure(result) {
1274
- if (!result || result.status !== 255) {
1301
+ if (!result || result.status === 0 || result.status == null) {
1275
1302
  return false;
1276
1303
  }
1277
1304
  const output = String(result.stderr || "") + String(result.stdout || "");
1305
+ if (isGcloudTransportFailure(output)) {
1306
+ return true;
1307
+ }
1308
+ if (result.status !== 255) {
1309
+ return false;
1310
+ }
1278
1311
  const transientRegex = /kex_exchange_identification|Connection closed|failed to connect to backend|Failed to connect to port 22|Connection refused|Connection reset|Connection timed out|4003|4047/i;
1279
1312
  return transientRegex.test(output);
1280
1313
  }
@@ -2471,16 +2504,34 @@ async function ensureEditorContainerUp(cfg) {
2471
2504
  // stream and capture, so the operator's view is preserved by echoing the
2472
2505
  // captured streams immediately below instead of live. Do NOT revert this to
2473
2506
  // "inherit"; classification depends on it.
2474
- const up = runGcloud(
2475
- buildGcloudArgs("ssh-probe", cfg, {
2476
- command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
2477
- }),
2478
- { stdio: ["ignore", "pipe", "pipe"] },
2507
+ //
2508
+ // Through the retry wrapper: a transport failure (an OS Login quota 429 on
2509
+ // 2026-10-01) means `up -d` never ran, so the honest response is to try again.
2510
+ // Retrying is safe without a did-it-run probe — `up -d` is idempotent here, and
2511
+ // a retry that races a container into existence lands on the name-conflict
2512
+ // verdict below, which converges.
2513
+ const up = await runGcloudSshWithRetry(
2514
+ "ssh-probe",
2515
+ cfg,
2516
+ { command: `cd ~/projects/${repoName} && ${composeCmd} up -d` },
2517
+ { stdio: ["ignore", "pipe", "pipe"], label: "docker compose up -d" },
2479
2518
  );
2480
2519
  if (up.stdout) process.stderr.write(up.stdout);
2481
2520
  if (up.stderr) process.stderr.write(up.stderr);
2482
2521
  if (up.status !== 0) {
2483
2522
  const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
2523
+ if (verdict === "transport-error") {
2524
+ // Retries exhausted and gcloud still never reached the VM. Say so, and mark
2525
+ // the error so the abort handler leaves the VM alone: nothing about the VM
2526
+ // has been learned, so it cannot be judged broken.
2527
+ const err = new Error(
2528
+ "could not reach the VM to run docker compose up -d: gcloud failed before connecting " +
2529
+ "(an OS Login API quota 429 — too many concurrent fleet ssh calls). The remote command " +
2530
+ "never ran; the VM itself was not shown to be broken.",
2531
+ );
2532
+ err.remoteCommandNeverRan = true;
2533
+ throw err;
2534
+ }
2484
2535
  if (verdict !== "name-conflict") {
2485
2536
  throw new Error("docker compose up -d failed on the VM");
2486
2537
  }
@@ -2991,32 +3042,66 @@ function buildVmAppPortReadCommand(repoName) {
2991
3042
  * `VITE_PORT` is genuinely correct there (it is what `resolved_port()` lands
2992
3043
  * on) and stays quiet. */
2993
3044
  async function readVmAppPort(cfg) {
2994
- const repoName = resolveProject(cfg).name;
2995
- const read = runGcloud(
2996
- buildGcloudArgs("ssh-probe", cfg, {
2997
- command: buildVmAppPortReadCommand(repoName),
2998
- }),
2999
- { stdio: ["ignore", "pipe", "ignore"] },
3000
- );
3001
- if (read.status !== 0 || typeof read.stdout !== "string") {
3045
+ const { port, failure } = await probeVmAppPort(cfg);
3046
+ if (failure && failure.kind === "ssh") {
3002
3047
  console.error(
3003
- `[cloud] WARNING: could not read the VM's app port over SSH — ` +
3048
+ `[cloud] WARNING: ${failure.what} — ` +
3004
3049
  `falling back to ${VITE_PORT}. If the app does not serve on ` +
3005
3050
  `${VITE_PORT}, the laptop port-forward targets the wrong port and the ` +
3006
3051
  `Live Preview will show "localhost refused to connect".`,
3007
3052
  );
3008
- return VITE_PORT;
3009
- }
3010
- if (read.stdout.includes(VM_APP_PORT_NO_CONFIG)) {
3053
+ } else if (failure) {
3011
3054
  console.error(
3012
- `[cloud] WARNING: no .codeyam/editor.json found under ~/projects on the ` +
3013
- `VM (looked for '${repoName}', then any project dir) — falling back to ` +
3055
+ `[cloud] WARNING: ${failure.what} — falling back to ` +
3014
3056
  `${VITE_PORT}. If the project is scaffolded under a different name and ` +
3015
3057
  `serves on another port, the port-forward targets the wrong port and ` +
3016
3058
  `the Live Preview will show "localhost refused to connect".`,
3017
3059
  );
3018
- return VITE_PORT;
3019
3060
  }
3061
+ return port == null ? VITE_PORT : port;
3062
+ }
3063
+
3064
+ /** The probe behind `readVmAppPort`, without the fallback: resolves to
3065
+ * `{ port, failure: null }` when the VM's config was read, or
3066
+ * `{ port: null, failure: { kind: "ssh" | "no-config", what } }` when it was
3067
+ * not. Callers that must not act on a guess — the ingress reconcile, where a
3068
+ * 5173 fallback would wire a preview NEG endpoint to a port nothing publishes —
3069
+ * use this to tell a resolved port from a fallback. Prints nothing; `what` is
3070
+ * the human description of the miss for the caller's own warning. */
3071
+ async function probeVmAppPort(cfg) {
3072
+ const repoName = resolveProject(cfg).name;
3073
+ const read = runGcloud(
3074
+ buildGcloudArgs("ssh-probe", cfg, {
3075
+ command: buildVmAppPortReadCommand(repoName),
3076
+ }),
3077
+ { stdio: ["ignore", "pipe", "ignore"] },
3078
+ );
3079
+ if (read.status !== 0 || typeof read.stdout !== "string") {
3080
+ return {
3081
+ port: null,
3082
+ failure: { kind: "ssh", what: "could not read the VM's app port over SSH" },
3083
+ };
3084
+ }
3085
+ const { port, miss } = parseVmDeclaredAppPort(read.stdout);
3086
+ if (miss === "no-config") {
3087
+ return {
3088
+ port: null,
3089
+ failure: {
3090
+ kind: "no-config",
3091
+ what:
3092
+ `no .codeyam/editor.json found under ~/projects on the ` +
3093
+ `VM (looked for '${repoName}', then any project dir)`,
3094
+ },
3095
+ };
3096
+ }
3097
+ return { port, failure: null };
3098
+ }
3099
+
3100
+ /** Resolve `buildVmAppPortReadCommand`'s output into `{ port, miss }` — shared
3101
+ * by `probeVmAppPort` and the port-drift probe, so both resolve the declared
3102
+ * port identically. A no-config VM is a miss with a null port, never 5173. */
3103
+ function parseVmDeclaredAppPort(stdout) {
3104
+ if (stdout.includes(VM_APP_PORT_NO_CONFIG)) return { port: null, miss: "no-config" };
3020
3105
  const parse = (s) => {
3021
3106
  try {
3022
3107
  const v = JSON.parse(s);
@@ -3025,8 +3110,116 @@ async function readVmAppPort(cfg) {
3025
3110
  return {};
3026
3111
  }
3027
3112
  };
3028
- const [baseJson = "", localJson = ""] = read.stdout.split("<<<LOCAL>>>");
3029
- return resolveConfiguredAppPort(parse(baseJson), parse(localJson));
3113
+ const [baseJson = "", localJson = ""] = stdout.split("<<<LOCAL>>>");
3114
+ return { port: resolveConfiguredAppPort(parse(baseJson), parse(localJson)), miss: null };
3115
+ }
3116
+
3117
+ /** Separates the declared-port read from the `docker ps` read in the combined
3118
+ * probe below. */
3119
+ const VM_PUBLISHED_PORT_MARKER = "<<<PUBLISHED>>>";
3120
+
3121
+ /** Read the declared app port AND the host port the VM's editor container
3122
+ * publishes (live `docker ps` bindings) in ONE SSH round-trip, so the roster
3123
+ * pass costs one extra probe per VM. Either side is null when unreadable. */
3124
+ function readVmAppPortsForDrift(cfg) {
3125
+ const read = runGcloud(
3126
+ buildGcloudArgs("ssh-probe", cfg, {
3127
+ command:
3128
+ `${buildVmAppPortReadCommand(resolveProject(cfg).name)}; ` +
3129
+ `echo '${VM_PUBLISHED_PORT_MARKER}'; ${buildPublishedAppPortReadCommand()}`,
3130
+ }),
3131
+ { stdio: ["ignore", "pipe", "ignore"] },
3132
+ );
3133
+ if (read.status !== 0) return { declared: null, published: null };
3134
+ return parseVmAppPortsForDrift(read.stdout);
3135
+ }
3136
+
3137
+ /** Split the combined probe's output into `{ declared, published }`. Output
3138
+ * without the marker — an SSH session that exited 0 having printed nothing, a
3139
+ * truncated read — is unreadable on BOTH sides. Resolving its empty config
3140
+ * half would land on the 5173 default and could report a drift that does not
3141
+ * exist. */
3142
+ function parseVmAppPortsForDrift(stdout) {
3143
+ if (typeof stdout !== "string" || !stdout.includes(VM_PUBLISHED_PORT_MARKER)) {
3144
+ return { declared: null, published: null };
3145
+ }
3146
+ const [declaredOut, publishedOut] = stdout.split(VM_PUBLISHED_PORT_MARKER);
3147
+ return {
3148
+ declared: declaredOut.trim() ? parseVmDeclaredAppPort(declaredOut).port : null,
3149
+ published: parsePublishedAppPort(publishedOut),
3150
+ };
3151
+ }
3152
+
3153
+ /** Read the VM's `cloud-session-idle.sh` verdict (`idle` | `active` | null).
3154
+ * Unknown — including a VM whose checkout predates the script — is null, and
3155
+ * null is never treated as idle. */
3156
+ function readVmIdleVerdict(cfg) {
3157
+ const read = runGcloud(
3158
+ buildGcloudArgs("ssh-probe", cfg, {
3159
+ command:
3160
+ `s="$HOME/codeyam-editor/scripts/cloud-session-idle.sh"; ` +
3161
+ `if [ -r "$s" ]; then bash "$s" 2>/dev/null; fi; true`,
3162
+ }),
3163
+ { stdio: ["ignore", "pipe", "ignore"] },
3164
+ );
3165
+ return read.status === 0 ? parseIdleVerdict(read.stdout) : null;
3166
+ }
3167
+
3168
+ /** Compare one VM's declared app port against its published host port over the
3169
+ * SSH probe. Returns the findings plus both raw reads (for the status line).
3170
+ * See npm/app-port-drift.js for the rule. */
3171
+ async function collectVmAppPortDrift(cfg, vmNumber, slug) {
3172
+ const { declared, published } = readVmAppPortsForDrift(cfg);
3173
+ const findings = await collectAppPortDrift(
3174
+ { vmNumber, slug },
3175
+ {
3176
+ readDeclaredPort: async () => declared,
3177
+ readPublishedPort: async () => published,
3178
+ readIdleVerdict: async () => readVmIdleVerdict(cfg),
3179
+ },
3180
+ );
3181
+ return { findings, declared, published };
3182
+ }
3183
+
3184
+ /** Port-drift pass over the whole roster, on the periodic reconcile that already
3185
+ * walks it. Reports, never repairs (the fix recreates the container or changes
3186
+ * the project's port — both the operator's call). Per-VM failures are logged
3187
+ * and swallowed, like the preview-origin heal beside it. */
3188
+ async function reportAppPortDriftAcrossRoster(cfg, roster, deps = {}) {
3189
+ const collect = deps.collectVmAppPortDrift || collectVmAppPortDrift;
3190
+ const drifted = [];
3191
+ for (const n of roster) {
3192
+ const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${n}` };
3193
+ try {
3194
+ const { findings } = await collect(perVmCfg, n, slugFor(n));
3195
+ for (const f of findings) {
3196
+ console.error(formatAppPortDriftWarning(f));
3197
+ drifted.push(f);
3198
+ }
3199
+ } catch (e) {
3200
+ console.error(` VM-${n} ${slugFor(n) || `vm-${n}`}: port-drift check skipped (${e && e.message})`);
3201
+ }
3202
+ }
3203
+ return drifted;
3204
+ }
3205
+
3206
+ /** `npm run cloud:app-port-drift -- --instance-name <name>` — one VM's
3207
+ * declared-vs-published comparison as the single `port-drift=` line
3208
+ * `vm-ingress.sh status` appends, plus the human warning on stderr. */
3209
+ async function cmdAppPortDrift(args, projectDir = rootDir) {
3210
+ const flags = parseFlags(args);
3211
+ const cfg = { ...(loadCloudConfig(projectDir) || {}), ...flags };
3212
+ const vmNumber = resolveFleetVmNumber(cfg.instanceName || "");
3213
+ if (vmNumber == null) {
3214
+ throw new Error("cloud:app-port-drift requires --instance-name <numbered fleet VM>.");
3215
+ }
3216
+ const { findings, declared, published } = await collectVmAppPortDrift(
3217
+ cfg,
3218
+ vmNumber,
3219
+ slugFor(vmNumber),
3220
+ );
3221
+ for (const f of findings) console.error(formatAppPortDriftWarning(f));
3222
+ console.log(formatPortDriftStatusLine(findings, { declared, published }));
3030
3223
  }
3031
3224
 
3032
3225
  /** Build the shell command that seeds a JSON patch into a host-side
@@ -3947,7 +4140,7 @@ function fleetInstancePrefix() {
3947
4140
  * (the VM is provisioned; ingress can be re-wired), Destroy likewise warns so a
3948
4141
  * teardown still removes the instance. `vmAccess` (up only) is the Add's intended
3949
4142
  * principal set, which the script's grant step uses in place of its sidecar read. */
3950
- function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
4143
+ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess, { appPortUnresolved = false } = {}) {
3951
4144
  const scriptPath = path.join(rootDir, "scripts", "gcp", "vm-ingress.sh");
3952
4145
  const inv = vmIngressInvocation({
3953
4146
  scriptPath,
@@ -3960,6 +4153,7 @@ function runVmIngress(cfg, action, vmNumber, domain, appPort, slug, vmAccess) {
3960
4153
  instancePrefix: fleetInstancePrefix(),
3961
4154
  slug,
3962
4155
  vmAccess,
4156
+ appPortUnresolved,
3963
4157
  });
3964
4158
  return spawnSync(inv.command, inv.args, {
3965
4159
  stdio: "inherit",
@@ -4773,12 +4967,18 @@ async function cmdUp(args, projectDir = rootDir) {
4773
4967
  // VM is in right now, then let the pure decision rule — see
4774
4968
  // abortTeardownDecision's doc comment for why each input is load-bearing.
4775
4969
  const state = probeAbortTeardownState(cfg, createdThisRun);
4776
- const verdict = abortTeardownDecision({ createdThisRun, ...state });
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 });
4777
4975
  if (createdThisRun && verdict === "keep") {
4778
4976
  // Deliberately loud and specific. The old blanket "tearing down the VM
4779
4977
  // cloud:up created this run (no orphan left behind)" was actively
4780
4978
  // misleading here — it asserted no orphan while deleting a working VM.
4781
- const why = state.probeInconclusive
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
4782
4982
  ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4783
4983
  : state.containerUp
4784
4984
  ? "its editor container is RUNNING — another run brought it up"
@@ -5071,6 +5271,25 @@ async function healPreviewOriginsAcrossRoster(cfg, roster) {
5071
5271
  * resources already present and creates only the missing backend / host rule.
5072
5272
  * A no-op in legacy quick-tunnel mode (no fleet domain). Invoked on demand and
5073
5273
  * automatically at dashboard boot so a fleet that lost ingress self-heals. */
5274
+ /** Resolve one reconcile target's app port for `vm-ingress.sh up`, refusing to
5275
+ * guess. Resolves `{ appPort, appPortUnresolved: false }` on a real read, or
5276
+ * `{ appPort: null, appPortUnresolved: true }` — with a warning naming the VM —
5277
+ * when the probe failed, so the script leaves the preview NEG's endpoints alone
5278
+ * instead of wiring (and pruning to) a 5173 fallback over a working endpoint.
5279
+ * `deps.probeVmAppPort` is injectable so tests never reach for SSH. */
5280
+ async function resolveReconcileAppPort(perVmCfg, vmNumber, deps = {}) {
5281
+ const probe = deps.probeVmAppPort || probeVmAppPort;
5282
+ const { port, failure } = await probe(perVmCfg);
5283
+ if (!failure) return { appPort: port, appPortUnresolved: false };
5284
+ console.error(
5285
+ `[cloud] WARNING: VM-${vmNumber}: ${failure.what} — skipping the preview ` +
5286
+ `endpoint converge rather than wiring ${VITE_PORT} over a working endpoint. ` +
5287
+ `If the preview NEG targets the wrong port, Live Preview requests fail ` +
5288
+ `with "upstream connect error" until a later reconcile resolves it.`,
5289
+ );
5290
+ return { appPort: null, appPortUnresolved: true };
5291
+ }
5292
+
5074
5293
  async function cmdReconcileIngress(args, projectDir = rootDir) {
5075
5294
  ensureGcloud();
5076
5295
  parseFlags(args);
@@ -5143,17 +5362,23 @@ async function cmdReconcileIngress(args, projectDir = rootDir) {
5143
5362
  for (const t of targets) {
5144
5363
  const label = t.slug || `vm-${t.vmNumber}`;
5145
5364
  // Resolve the VM's real dev-server port so a re-created preview NEG targets
5146
- // the right port (not the 5173 bootstrap fallback). Best-effort — readVmAppPort
5147
- // itself falls back to VITE_PORT on any SSH/parse failure.
5365
+ // the right port. NOT readVmAppPort: its VITE_PORT fallback on an SSH/parse
5366
+ // failure is indistinguishable from a VM genuinely serving 5173, and `up`
5367
+ // prunes the preview NEG to the port it is given — a fallback would detach the
5368
+ // working endpoint and wire one nothing publishes. Unresolved → the script
5369
+ // leaves the preview endpoints alone; every other resource still converges.
5148
5370
  const perVmCfg = { ...cfg, instanceName: `${fleetInstancePrefix()}${t.vmNumber}` };
5149
- const appPort = await readVmAppPort(perVmCfg);
5150
- const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug);
5371
+ const { appPort, appPortUnresolved } = await resolveReconcileAppPort(perVmCfg, t.vmNumber);
5372
+ const res = runVmIngress(perVmCfg, "up", t.vmNumber, domain, appPort, t.slug, undefined, {
5373
+ appPortUnresolved,
5374
+ });
5151
5375
  console.error(
5152
5376
  ` VM-${t.vmNumber} ${label}: ${t.verdict} → ${res.status === 0 ? "re-wired" : "re-wire FAILED"}`,
5153
5377
  );
5154
5378
  }
5155
5379
 
5156
5380
  await healPreviewOriginsAcrossRoster(cfg, roster);
5381
+ await reportAppPortDriftAcrossRoster(cfg, roster);
5157
5382
 
5158
5383
  // Converge each rostered backend-service's timeout to 86400 (24h). A fully-wired
5159
5384
  // BES still at GCP's 30s default reports `wired` above and is skipped by the
@@ -5722,6 +5947,7 @@ const HANDLERS = {
5722
5947
  status: cmdStatus,
5723
5948
  "ingress-up": cmdIngressUp,
5724
5949
  "reconcile-ingress": cmdReconcileIngress,
5950
+ "app-port-drift": cmdAppPortDrift,
5725
5951
  "wire-add-tail": cmdWireAddTail,
5726
5952
  "reconcile-credentials": cmdReconcileCredentials,
5727
5953
  "reconcile-admin": cmdReconcileAdmin,
@@ -5816,6 +6042,8 @@ module.exports = {
5816
6042
  seedPreviewOrigin,
5817
6043
  healPreviewOriginAtReady,
5818
6044
  healPreviewOriginsAcrossRoster,
6045
+ reportAppPortDriftAcrossRoster,
6046
+ cmdAppPortDrift,
5819
6047
  buildGcloudArgs,
5820
6048
  sshScriptCommand,
5821
6049
  bootstrapRunnerScript,
@@ -5905,6 +6133,10 @@ module.exports = {
5905
6133
  classifyGitRemoteError,
5906
6134
  decideCredentialHeal,
5907
6135
  readVmAppPort,
6136
+ probeVmAppPort,
6137
+ resolveReconcileAppPort,
6138
+ parseVmDeclaredAppPort,
6139
+ parseVmAppPortsForDrift,
5908
6140
  buildVmAppPortReadCommand,
5909
6141
  resolveLaptopGitIdentity,
5910
6142
  gitBranchRefExists,
@@ -31,12 +31,44 @@
31
31
  // whole captured stream rather than line- or anchor-based.
32
32
  const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
33
33
 
34
+ // The stable fragments of a gcloud CLIENT-SIDE API failure — gcloud's own HTTP
35
+ // client failed before any ssh was attempted, so the remote command never ran.
36
+ // Observed verbatim in the 2026-10-01 VM-8 add log, when an OS Login
37
+ // loginProfile read hit the `ReadRequestsPerMinutePerUser` quota (limit 60):
38
+ //
39
+ // ERROR: (gcloud.compute.ssh) HttpError accessing <https://oslogin.googleapis.com/…
40
+ // /loginProfile?…>: response: <{'status': 429}>, content <{"error": {"code": 429,
41
+ // "message": "Quota exceeded for quota metric …", "status": "RESOURCE_EXHAUSTED",
42
+ // "details": [{"reason": "RATE_LIMIT_EXCEEDED"}]}}
43
+ //
44
+ // Same discipline as the conflict fragments, for the same reason — Google
45
+ // rephrases these: require gcloud's `HttpError accessing` wrapper AND at least
46
+ // one quota token, never a single token, so an app-level message that merely
47
+ // contains `429` is not swept in.
48
+ const GCLOUD_HTTP_ERROR_FRAGMENT = /HttpError accessing/;
49
+ const GCLOUD_QUOTA_FRAGMENTS = [/'status':\s*429\b/, /RESOURCE_EXHAUSTED/, /RATE_LIMIT_EXCEEDED/];
50
+
51
+ /** True when `text` carries a gcloud client-side API quota failure, i.e. the
52
+ * gcloud client never reached the VM. Shared by {@link classifyComposeUpFailure}
53
+ * and `cloud.js`'s transient-ssh predicate and abort handler, so the three
54
+ * cannot drift apart on what counts as "never reached the VM". */
55
+ function isGcloudTransportFailure(text) {
56
+ const s = String(text == null ? '' : text);
57
+ return GCLOUD_HTTP_ERROR_FRAGMENT.test(s) && GCLOUD_QUOTA_FRAGMENTS.some((re) => re.test(s));
58
+ }
59
+
34
60
  /** Classify the outcome of `docker compose up -d`.
35
61
  *
36
62
  * Returns one of:
37
63
  * - `'ok'` — the command succeeded; there is no failure to classify. Guards
38
64
  * against a caller that classifies unconditionally, so a zero status can
39
65
  * never be read as a conflict.
66
+ * - `'transport-error'` — the gcloud ssh client never reached the VM (an OS
67
+ * Login quota 429 on 2026-10-01), so `up -d` did not run and nothing has been
68
+ * learned about the box. Reading this as `'failed'` is what drove the abort
69
+ * handler into deleting VM-8 after it had already built its editor image.
70
+ * The caller retries; it must never treat this as a provisioning failure.
71
+ * Checked first so a transport failure can never be read as a daemon message.
40
72
  * - `'name-conflict'` — the daemon refused because a container of that name
41
73
  * already exists, i.e. a concurrent run already started it. The caller
42
74
  * re-probes for a running container and converges instead of throwing.
@@ -50,10 +82,11 @@ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already
50
82
  function classifyComposeUpFailure({ status, stderr } = {}) {
51
83
  if (status === 0) return 'ok';
52
84
  const text = String(stderr == null ? '' : stderr);
85
+ if (isGcloudTransportFailure(text)) return 'transport-error';
53
86
  if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
54
87
  return 'name-conflict';
55
88
  }
56
89
  return 'failed';
57
90
  }
58
91
 
59
- module.exports = { classifyComposeUpFailure };
92
+ module.exports = { classifyComposeUpFailure, isGcloudTransportFailure };