@codeyam-editor/codeyam-editor 0.1.6 → 0.1.7

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 (29) hide show
  1. package/npm/cloud.js +192 -29
  2. package/npm/compose-conflict.js +59 -0
  3. package/npm/fleet-add-failure.js +123 -14
  4. package/npm/fleet-cleanup.js +129 -0
  5. package/npm/fleet-job-badge.js +28 -12
  6. package/npm/fleet-memory.js +44 -0
  7. package/npm/fleet-probe-transport.js +155 -0
  8. package/npm/fleet-revision.js +104 -0
  9. package/npm/fleet-running-plan.js +37 -7
  10. package/npm/launch-config.js +120 -4
  11. package/npm/scenario-check.js +52 -18
  12. package/npm/scenario-playwright.js +44 -10
  13. package/package.json +5 -5
  14. package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  15. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  16. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  17. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  18. package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  19. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  20. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  21. package/ui/dist/assets/{index-DCHqRrwO.js → index-VuyqBbGJ.js} +2 -2
  22. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  23. package/ui/dist/index.html +2 -2
  24. package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
  25. package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
  26. package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
  27. package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
  28. package/ui/dist/assets/index-BygCiJSb.css +0 -1
  29. package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
package/npm/cloud.js CHANGED
@@ -105,6 +105,10 @@ const {
105
105
  // edit (the invisible-VM gap improve38 closes). `readRoster` enumerates the
106
106
  // fleet for `reconcile-ingress`.
107
107
  const { addToRoster, readRoster } = require("./fleet-roster");
108
+ // Tells a docker container-name conflict (a concurrent run already started this
109
+ // container) apart from a real `docker compose up -d` failure, so the abort path
110
+ // can converge instead of escalating to instance deletion.
111
+ const { classifyComposeUpFailure } = require("./compose-conflict");
108
112
 
109
113
  // =============================================================================
110
114
  // Pure helpers — every export here is unit-tested in cloud.test.js without
@@ -845,6 +849,79 @@ function parseEditorComposeState(stdout) {
845
849
  return null;
846
850
  }
847
851
 
852
+ /** Decide whether `cloud:up`'s abort handler may delete the instance it is
853
+ * unwinding. Pure `{ createdThisRun, containerUp, rostered, probeInconclusive }
854
+ * → 'delete' | 'keep'`, matching the `reapVerdict` / `reattachVerdict` /
855
+ * `reconcileRosteredVm` pattern in npm/fleet-cleanup.js, so the whole matrix is
856
+ * exhaustively testable without a VM.
857
+ *
858
+ * `createdThisRun` stays a NECESSARY condition — never delete an instance this
859
+ * run did not create — but 2026-08-07 proved it is not SUFFICIENT. It answers
860
+ * "did I create this?", not "is it safe to destroy?", and in a concurrent
861
+ * provision race the losing run legitimately owns the flag while the winner has
862
+ * already brought the VM up and rostered it. Deleting there destroyed VM-4 and
863
+ * VM-5. So authorization also requires CURRENT-STATE evidence that the VM is
864
+ * out of service:
865
+ *
866
+ * - `containerUp` — the editor container is running (per the compose-ps probe).
867
+ * A VM serving an editor is in use by definition.
868
+ * - `rostered` — the VM is in the fleet roster, the operator-visible source of
869
+ * truth. Note this is true whether the winning run or THIS run rostered it;
870
+ * either way the VM is visible in the dashboard and removable in one command,
871
+ * so it is not the silent orphan teardown exists to prevent.
872
+ * - `probeInconclusive` — liveness could not be determined. This must fail
873
+ * SAFE: a failing probe is how we reach the abort handler in the first place,
874
+ * so it can never count as evidence the VM is dead. An orphan costs one
875
+ * `npm run cloud:destroy`; a wrong delete costs a 15-40 minute rebuild plus
876
+ * any unpushed work on the VM. */
877
+ function abortTeardownDecision({ createdThisRun, containerUp, rostered, probeInconclusive } = {}) {
878
+ if (!createdThisRun) return "keep";
879
+ if (probeInconclusive) return "keep";
880
+ if (containerUp) return "keep";
881
+ if (rostered) return "keep";
882
+ return "delete";
883
+ }
884
+
885
+ /** Gather the CURRENT-STATE inputs {@link abortTeardownDecision} needs, so the
886
+ * abort handler reads gather → decide → act instead of inlining probes among
887
+ * the delete calls.
888
+ *
889
+ * Only runs the probes when `createdThisRun` is true — the decision is `keep`
890
+ * regardless otherwise, and an abort path should not spend SSH round trips
891
+ * proving something it already knows. In that case it returns the
892
+ * probe-inconclusive shape, which is the safe default at every layer.
893
+ *
894
+ * Both lookups are wrapped: a throwing probe or an unreadable roster must fail
895
+ * SAFE (toward `keep`), never take a second exception path into the delete.
896
+ * `deps` is injectable so the matrix is unit-tested without a VM. */
897
+ function probeAbortTeardownState(cfg, createdThisRun, deps = {}) {
898
+ if (!createdThisRun) {
899
+ return { containerUp: false, rostered: false, probeInconclusive: true };
900
+ }
901
+ const probe = deps.probeEditorComposeState || probeEditorComposeState;
902
+ const roster = deps.readRoster || readRoster;
903
+ const resolveVm = deps.resolveFleetVmNumber || resolveFleetVmNumber;
904
+ const log = deps.log || ((m) => console.error(m));
905
+
906
+ let liveness = { state: null, inconclusive: true };
907
+ try {
908
+ liveness = probe(cfg);
909
+ } catch (e) {
910
+ log(` (liveness probe failed: ${e && e.message})`);
911
+ }
912
+ let rostered = false;
913
+ try {
914
+ const vm = resolveVm(cfg.instanceName);
915
+ rostered = vm != null && (roster() || []).includes(vm);
916
+ } catch { /* unreadable roster — treated as not-rostered; liveness still guards */ }
917
+
918
+ return {
919
+ containerUp: liveness.state === "running",
920
+ rostered,
921
+ probeInconclusive: liveness.inconclusive,
922
+ };
923
+ }
924
+
848
925
  /** Translate one of the wrapper's verbs into the exact `gcloud` argv. The
849
926
  * switch's job is to keep every per-verb command shape in one reviewable
850
927
  * place — tests assert the arrays without ever spawning a process. */
@@ -2049,20 +2126,65 @@ async function ensureProjectBootstrapped(cfg, { sleepFn } = {}) {
2049
2126
  }
2050
2127
  }
2051
2128
 
2052
- async function ensureEditorContainerUp(cfg) {
2129
+ /** Run the compose-ps liveness probe over SSH and report BOTH the editor
2130
+ * service's state and whether the probe itself could be trusted.
2131
+ *
2132
+ * The `|| true` keeps the remote pipeline's exit status 0 whenever SSH itself
2133
+ * worked, so a non-zero `status` means the transport failed (auth/SSH/IAP) —
2134
+ * not that the container is down. Callers that must fail safe (the abort
2135
+ * teardown) need that distinction; `parseEditorComposeState` alone collapses
2136
+ * "no container" and "could not ask" into the same `null`.
2137
+ *
2138
+ * Returns `{ state, inconclusive }` where `state` is the compose `State` string
2139
+ * (`"running"`, `"exited"`, …) or null when the service is absent, and
2140
+ * `inconclusive` is true only for a transport failure. */
2141
+ function probeEditorComposeState(cfg) {
2053
2142
  const repoName = resolveProject(cfg).name;
2054
- // -f docker-compose.cloud.yml everywhere: the editor repo's own
2055
- // docker-compose.yml does `build: .` from source and would shadow the
2056
- // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
2057
2143
  const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2058
- console.error("Checking editor container state...");
2059
2144
  const probe = runGcloud(
2060
2145
  buildGcloudArgs("ssh-probe", cfg, {
2061
- command: `cd ~/projects/${repoName} && ${composeCmd} ps --format json 2>/dev/null || true`,
2146
+ command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2062
2147
  }),
2063
2148
  { stdio: ["ignore", "pipe", "ignore"] },
2064
2149
  );
2065
- if (probe.status === 0 && parseEditorComposeState(probe.stdout) === "running") {
2150
+ if (probe.status !== 0) {
2151
+ return { state: null, inconclusive: true };
2152
+ }
2153
+ return { state: parseEditorComposeState(probe.stdout), inconclusive: false };
2154
+ }
2155
+
2156
+ /** Poll {@link probeEditorComposeState} until the editor service reads
2157
+ * `running`, returning true as soon as it does. Used to close the TOCTOU window
2158
+ * after a container-name conflict: the winning run may still be inside
2159
+ * `Creating`/`Created` when the conflict surfaces, so a single immediate probe
2160
+ * can under-read and report a healthy container as absent.
2161
+ *
2162
+ * Deliberately short (default ~30s): this is not the multi-minute first-boot
2163
+ * budget `waitForContainerRunning` grants, it is the tail of a start that
2164
+ * another process has already begun. `sleepFn` and `probeFn` are injectable so
2165
+ * a unit test drives the whole loop with neither wall-clock delay nor a VM. */
2166
+ async function waitForEditorComposeRunning(cfg, { attempts = 6, delayMs = 5000, sleepFn, probeFn } = {}) {
2167
+ const sleep = sleepFn || ((ms) => new Promise((r) => setTimeout(r, ms)));
2168
+ const probe = probeFn || probeEditorComposeState;
2169
+ for (let i = 0; i < attempts; i++) {
2170
+ if (probe(cfg).state === "running") {
2171
+ return true;
2172
+ }
2173
+ if (i < attempts - 1) {
2174
+ await sleep(delayMs);
2175
+ }
2176
+ }
2177
+ return false;
2178
+ }
2179
+
2180
+ async function ensureEditorContainerUp(cfg) {
2181
+ const repoName = resolveProject(cfg).name;
2182
+ // -f docker-compose.cloud.yml everywhere: the editor repo's own
2183
+ // docker-compose.yml does `build: .` from source and would shadow the
2184
+ // cloud variant when self-hosting (see gcp-project-bootstrap.sh).
2185
+ const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2186
+ console.error("Checking editor container state...");
2187
+ if (probeEditorComposeState(cfg).state === "running") {
2066
2188
  console.error(" ✓ editor container is already running");
2067
2189
  // containerStarted: the container is confirmed up, so the CLI install runs
2068
2190
  // its POST-start path with no wait — see the precondition on ensureAgentCliInstalled.
@@ -2070,14 +2192,47 @@ async function ensureEditorContainerUp(cfg) {
2070
2192
  return;
2071
2193
  }
2072
2194
  console.error(" starting editor container (docker compose up -d)...");
2195
+ // CAPTURE rather than `stdio: "inherit"`: the daemon writes its container-name
2196
+ // conflict to stderr, and with "inherit" that text went straight to our parent
2197
+ // and was never observable here — so the conflict could not be told apart from
2198
+ // a real failure (the 2026-08-07 VM-4/VM-5 delete). spawnSync cannot both
2199
+ // stream and capture, so the operator's view is preserved by echoing the
2200
+ // captured streams immediately below instead of live. Do NOT revert this to
2201
+ // "inherit"; classification depends on it.
2073
2202
  const up = runGcloud(
2074
2203
  buildGcloudArgs("ssh-probe", cfg, {
2075
2204
  command: `cd ~/projects/${repoName} && ${composeCmd} up -d`,
2076
2205
  }),
2077
- { stdio: "inherit" },
2206
+ { stdio: ["ignore", "pipe", "pipe"] },
2078
2207
  );
2208
+ if (up.stdout) process.stderr.write(up.stdout);
2209
+ if (up.stderr) process.stderr.write(up.stderr);
2079
2210
  if (up.status !== 0) {
2080
- throw new Error("docker compose up -d failed on the VM");
2211
+ const verdict = classifyComposeUpFailure({ status: up.status, stderr: `${up.stderr || ""}\n${up.stdout || ""}` });
2212
+ if (verdict !== "name-conflict") {
2213
+ throw new Error("docker compose up -d failed on the VM");
2214
+ }
2215
+ // A name conflict means a concurrent run already created this container —
2216
+ // the goal we were about to pursue is being reached by someone else. Confirm
2217
+ // that positively before converging: a conflict whose container never
2218
+ // reaches `running` (e.g. a stale STOPPED container squatting the name) is a
2219
+ // genuine problem and must still fail loudly, or convergence becomes a
2220
+ // catch-all that papers over real breakage.
2221
+ console.error(
2222
+ " a concurrent run already created the editor container — confirming it reaches running...",
2223
+ );
2224
+ if (!(await waitForEditorComposeRunning(cfg))) {
2225
+ throw new Error(
2226
+ "docker compose up -d hit a container-name conflict on the VM, but the conflicting " +
2227
+ "container never reached running (a stale stopped container may be holding the name). " +
2228
+ `Inspect with: gcloud compute ssh ${cfg.instanceName} --command 'cd ~/projects/${repoName} && ${composeCmd} ps -a'`,
2229
+ );
2230
+ }
2231
+ console.error(" ✓ editor container is running (started by a concurrent run)");
2232
+ // containerStarted: the container is confirmed up — same post-start contract
2233
+ // as the already-running short-circuit above.
2234
+ await ensureAgentCliInstalled(cfg, { containerStarted: true });
2235
+ return;
2081
2236
  }
2082
2237
  // containerStarted: `up -d` returned 0, so the CLI install is legitimately a
2083
2238
  // post-start step. A brief wait-for-running here (slow boot settling) is
@@ -2093,18 +2248,10 @@ async function ensureEditorContainerUp(cfg) {
2093
2248
  // success. This throws (non-zero exit) when the editor service isn't
2094
2249
  // `running`, converting a silent partial provision into a loud, actionable
2095
2250
  // failure the operator (and the dashboard's fail-fast-on-nonzero path) can act
2096
- // on. The `|| true` keeps the probe's exit status 0 so the real signal is the
2097
- // parsed compose state, not the SSH/cd exit code.
2251
+ // on. An inconclusive probe is not "running" here — a terminal gate must have
2252
+ // positive evidence, and the caller's job is to fail loudly without it.
2098
2253
  async function assertEditorContainerRunning(cfg) {
2099
- const repoName = resolveProject(cfg).name;
2100
- const composeCmd = `docker compose -f docker-compose.cloud.yml`;
2101
- const probe = runGcloud(
2102
- buildGcloudArgs("ssh-probe", cfg, {
2103
- command: `cd ~/projects/${repoName} 2>/dev/null && ${composeCmd} ps --format json 2>/dev/null || true`,
2104
- }),
2105
- { stdio: ["ignore", "pipe", "ignore"] },
2106
- );
2107
- if (!(probe.status === 0 && parseEditorComposeState(probe.stdout) === "running")) {
2254
+ if (probeEditorComposeState(cfg).state !== "running") {
2108
2255
  throw new Error(
2109
2256
  `provisioning incomplete: editor container is not running on ${cfg.instanceName} ` +
2110
2257
  `(no /workspace or container). Re-run: npm run cloud:up -- --instance-name ${cfg.instanceName}`,
@@ -4122,7 +4269,27 @@ async function cmdUp(args, projectDir = rootDir) {
4122
4269
  );
4123
4270
  }
4124
4271
  } catch (err) {
4125
- if (createdThisRun) {
4272
+ // `createdThisRun` alone MUST NOT authorize deletion. Gather what state the
4273
+ // VM is in right now, then let the pure decision rule — see
4274
+ // abortTeardownDecision's doc comment for why each input is load-bearing.
4275
+ const state = probeAbortTeardownState(cfg, createdThisRun);
4276
+ const verdict = abortTeardownDecision({ createdThisRun, ...state });
4277
+ if (createdThisRun && verdict === "keep") {
4278
+ // Deliberately loud and specific. The old blanket "tearing down the VM
4279
+ // cloud:up created this run (no orphan left behind)" was actively
4280
+ // misleading here — it asserted no orphan while deleting a working VM.
4281
+ const why = state.probeInconclusive
4282
+ ? "its liveness could not be confirmed (probe failed), and an unconfirmed VM is never deleted"
4283
+ : state.containerUp
4284
+ ? "its editor container is RUNNING — another run brought it up"
4285
+ : "it is in the fleet roster and visible to the dashboard";
4286
+ console.error(
4287
+ `✗ provisioning aborted — the VM ${cfg.instanceName} was LEFT IN PLACE because ${why}.\n` +
4288
+ ` This run created the instance, but that alone does not authorize deleting a VM that is in service.\n` +
4289
+ ` If it really should go, remove it deliberately: npm run cloud:destroy`,
4290
+ );
4291
+ }
4292
+ if (verdict === "delete") {
4126
4293
  console.error(
4127
4294
  `✗ provisioning aborted — tearing down the VM cloud:up created this run (no orphan left behind)`,
4128
4295
  );
@@ -4304,14 +4471,7 @@ async function cmdStatus(args, projectDir = rootDir) {
4304
4471
  const imgStatus = String(imgProbe.stdout || "").trim() || "?";
4305
4472
  console.error(` image: ${imgStatus}`);
4306
4473
 
4307
- const repoName = resolveProject(cfg).name;
4308
- const containerProbe = runGcloud(
4309
- buildGcloudArgs("ssh-probe", cfg, {
4310
- command: `cd ~/projects/${repoName} 2>/dev/null && docker compose -f docker-compose.cloud.yml ps --format json 2>/dev/null || echo`,
4311
- }),
4312
- { stdio: ["ignore", "pipe", "ignore"] },
4313
- );
4314
- const containerStatus = parseEditorComposeState(containerProbe.stdout) || "missing";
4474
+ const containerStatus = probeEditorComposeState(cfg).state || "missing";
4315
4475
  console.error(` editor container: ${containerStatus}`);
4316
4476
 
4317
4477
  const appPort = await readVmAppPort(cfg);
@@ -5058,6 +5218,9 @@ module.exports = {
5058
5218
  assertAgentCliInstalled,
5059
5219
  probeContainerRunning,
5060
5220
  probeContainerState,
5221
+ waitForEditorComposeRunning,
5222
+ abortTeardownDecision,
5223
+ probeAbortTeardownState,
5061
5224
  probeAgentBinaryInstalled,
5062
5225
  waitForContainerRunning,
5063
5226
  KNOWN_FLAGS,
@@ -0,0 +1,59 @@
1
+ 'use strict';
2
+
3
+ // npm/compose-conflict.js — classify a failed `docker compose up -d` on a fleet
4
+ // VM, so `cloud:up` can tell "another run beat me to this container" apart from
5
+ // "provisioning genuinely broke".
6
+ //
7
+ // Why this exists: on 2026-08-07 two concurrent `cloud:up` runs raced to start
8
+ // the editor container on one fresh VM. The loser's `up -d` exited non-zero with
9
+ // the docker daemon's container-name conflict, `cloud.js` flattened that into a
10
+ // generic "docker compose up -d failed on the VM", and the abort handler
11
+ // escalated it all the way to deleting a VM the winner had already brought up
12
+ // and rostered. A name conflict is the opposite of a failure — it is proof the
13
+ // shared goal (container running) was reached by someone else.
14
+ //
15
+ // Matching a daemon error string is exactly the kind of thing that rots when
16
+ // docker rephrases it, so it lives here as a named, unit-tested function with
17
+ // the real observed message pinned as a fixture — not as an inline regex at the
18
+ // call site.
19
+
20
+ // The stable fragments of the daemon's conflict message. The container id and
21
+ // name vary per run and docker's exact phrasing has changed across versions, so
22
+ // we require all three fragments rather than the whole sentence. Observed
23
+ // verbatim in the VM-4 add log:
24
+ //
25
+ // Error response from daemon: Conflict. The container name
26
+ // "/codeyam-editor-editor-1" is already in use by container "c9d448c6…".
27
+ // You have to remove (or rename) that container to be able to reuse that name.
28
+ //
29
+ // Compose emits it twice (once prefixed `service:editor:1`, once bare) and
30
+ // interleaves its own progress lines, so the match is substring-based over the
31
+ // whole captured stream rather than line- or anchor-based.
32
+ const NAME_CONFLICT_FRAGMENTS = [/\bConflict\./i, /container name/i, /is already in use/i];
33
+
34
+ /** Classify the outcome of `docker compose up -d`.
35
+ *
36
+ * Returns one of:
37
+ * - `'ok'` — the command succeeded; there is no failure to classify. Guards
38
+ * against a caller that classifies unconditionally, so a zero status can
39
+ * never be read as a conflict.
40
+ * - `'name-conflict'` — the daemon refused because a container of that name
41
+ * already exists, i.e. a concurrent run already started it. The caller
42
+ * re-probes for a running container and converges instead of throwing.
43
+ * - `'failed'` — anything else. Existing behavior: a real provisioning
44
+ * failure.
45
+ *
46
+ * Pure: `{ status, stderr } → tag`, with no I/O, so the whole matrix is
47
+ * testable without a VM. `stderr` may be null/undefined (the pre-fix call site
48
+ * captured nothing), which classifies as `'failed'` — the conservative
49
+ * direction, since converging requires positive evidence of a conflict. */
50
+ function classifyComposeUpFailure({ status, stderr } = {}) {
51
+ if (status === 0) return 'ok';
52
+ const text = String(stderr == null ? '' : stderr);
53
+ if (NAME_CONFLICT_FRAGMENTS.every((re) => re.test(text))) {
54
+ return 'name-conflict';
55
+ }
56
+ return 'failed';
57
+ }
58
+
59
+ module.exports = { classifyComposeUpFailure };
@@ -96,6 +96,67 @@ function transcriptRostered(logTail) {
96
96
  return parseRosterMarkers(String(logTail || '')).length > 0;
97
97
  }
98
98
 
99
+ // The categories that describe an add whose outcome is NOT yet proven bad — the
100
+ // calm, in-flight side of the classifier. `converging` is a rostered box whose
101
+ // ingress is still wiring; `verifying` is the residue: no failure signature
102
+ // matched and nothing durable proves the VM is dead. Both render a calm card and
103
+ // neither may use the word "failed". Consumed by holdConvergingBadge and by
104
+ // deriveStatus (server.js) to pick the matching calm status.
105
+ const CALM_ADD_CATEGORIES = new Set(['converging', 'verifying']);
106
+
107
+ // Is a non-zero add outcome CORROBORATED by a durable fact — i.e. do we have
108
+ // positive proof of death, rather than merely an absence of proof of life?
109
+ //
110
+ // This is the conjunction that gates the red `unknown` verdict. Before it, the
111
+ // classifier's fallthrough treated "no recognized success signature" as failure,
112
+ // so "we don't know" rendered as a terminal red "Add failed" telling the operator
113
+ // to destroy a VM whose container was Up and whose editor answered /api/health
114
+ // 200 (VM-4, 2026-07-31). Red now requires BOTH halves:
115
+ //
116
+ // 1. A non-zero exit code. A *missing* code is not proof of death — today it
117
+ // usually means the wrapper was signal-killed while its provision ran on,
118
+ // which is exactly the case that was misreading as failure. `null`,
119
+ // `undefined`, a non-integer, and 0 all fail this half.
120
+ // 2. At least one durable corroborating fact:
121
+ // - `instanceAlive === false` GCP says the instance is gone.
122
+ // - `containerHealthy === false` the in-VM editor was probed and is not
123
+ // answering — the box exists but never came up.
124
+ // - `wiringDeadlineBlown` the wire-add-tail gave up (the failed
125
+ // marker is the tail's own honest deadline signal).
126
+ //
127
+ // Every fact is TRI-STATE on purpose: `undefined`/`null` means "not probed" and
128
+ // corroborates nothing. Only an explicit `false` counts, so an unprobed VM can
129
+ // never be escalated to red by absence of evidence.
130
+ function addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
131
+ const code = exitCode == null ? NaN : Number(exitCode);
132
+ if (!Number.isInteger(code) || code === 0) return false;
133
+ return instanceAlive === false || containerHealthy === false || !!wiringDeadlineBlown;
134
+ }
135
+
136
+ // Resolve the two independent health signals into the single tri-state
137
+ // `containerHealthy` fact addFailureCorroborated consumes. Pure so the precedence
138
+ // — which decides whether a VM can be escalated to red — is unit-tested; the
139
+ // server (addFailureFacts) supplies the live signals.
140
+ //
141
+ // reachable the editor answered over the LB.
142
+ // inVmHealthy the STRAND_PROBE /api/health read from INSIDE the container.
143
+ //
144
+ // LB reachability wins when present: it is the stronger witness (it proves the
145
+ // whole URL→ingress→backend path, not just the process). Otherwise the in-VM
146
+ // witness answers — this is the still-wiring case, where the LB necessarily
147
+ // cannot see a VM whose ingress is what we are waiting on, yet the editor itself
148
+ // is demonstrably up.
149
+ //
150
+ // Returns `null` when NEITHER signal was gathered. That is the load-bearing case:
151
+ // an unprobed VM must corroborate nothing, so it can never be escalated to red by
152
+ // absence of evidence. `false` is reserved for a VM that was genuinely probed and
153
+ // did not answer.
154
+ function containerHealthWitness({ reachable, inVmHealthy } = {}) {
155
+ if (reachable) return true;
156
+ if (inVmHealthy != null) return !!inVmHealthy;
157
+ return null;
158
+ }
159
+
99
160
  // Build a verdict, stamping `retryable` from the single-source-of-truth Set so it
100
161
  // can never drift from the category.
101
162
  function buildFailureVerdict(category, headline, recovery, extra) {
@@ -108,7 +169,16 @@ function buildFailureVerdict(category, headline, recovery, extra) {
108
169
  //
109
170
  // logTail the last lines of the provision transcript (string; '' when the
110
171
  // log was unreadable — must never throw)
111
- // exitCode the provision's exit code (number; used for signal-kill detection)
172
+ // exitCode the provision's exit code (number; used for signal-kill detection
173
+ // and as the first half of the proof-of-death conjunction)
174
+ //
175
+ // The remaining inputs are the durable facts the server gathers (the module
176
+ // stays pure — it never probes). All are tri-state; omit any that was not
177
+ // probed and it corroborates nothing. See addFailureCorroborated.
178
+ //
179
+ // instanceAlive does GCP still list the instance?
180
+ // containerHealthy did the in-VM editor answer /api/health?
181
+ // wiringDeadlineBlown did the add-time wire tail drop its failed marker?
112
182
  //
113
183
  // Returns { category, headline, recovery, retryable, files? }.
114
184
  //
@@ -118,7 +188,7 @@ function buildFailureVerdict(category, headline, recovery, extra) {
118
188
  // signature is therefore tested BEFORE the generic cloud-up-failed signature, or
119
189
  // a retryable drift would misclassify as a non-retryable cloud:up failure and the
120
190
  // operator would lose the safe one-click recovery.
121
- function classifyAddFailure({ logTail, exitCode } = {}) {
191
+ function classifyAddFailure({ logTail, exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown } = {}) {
122
192
  const log = String(logTail || '');
123
193
 
124
194
  // 1. preflight-drift — the streamed-script drift guard aborted BEFORE any
@@ -218,9 +288,34 @@ function classifyAddFailure({ logTail, exitCode } = {}) {
218
288
  'The container is up and the VM is rostered; the LB ingress is still wiring in the background. This resolves on its own once the editor is reachable over the LB — no action needed unless it persists past the wiring deadline.');
219
289
  }
220
290
 
221
- // 9. unknown — fallback. Never invite an unproven retry.
222
- return buildFailureVerdict('unknown', 'Add failed',
223
- 'The add failed for an unclassified reason — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
291
+ // 9. The residue: no failure signature above matched. The polarity here is
292
+ // INVERTED from the original design. It used to read "no recognized success
293
+ // signature ⇒ failed", which made red the DEFAULT for "we don't know" — so a
294
+ // VM whose container was Up and whose editor answered /api/health 200 got a
295
+ // terminal red "Add failed" card advising the operator to destroy it (VM-4,
296
+ // 2026-07-31; the wrapper had been killed mid-flight, so its transcript was
297
+ // truncated before CY_ROSTER and it never reached the `converging` arm above).
298
+ //
299
+ // Red now requires positive proof of death, and everything else is in-flight:
300
+ //
301
+ // 9a. unknown — a non-zero exit CORROBORATED by a durable fact (see
302
+ // addFailureCorroborated). The box is provably dead or provably never
303
+ // came up, so the loud, terminal escalation is warranted and unchanged.
304
+ // Still never invites an unproven retry.
305
+ if (addFailureCorroborated({ exitCode, instanceAlive, containerHealthy, wiringDeadlineBlown })) {
306
+ return buildFailureVerdict('unknown', 'Add failed',
307
+ 'The add failed for an unclassified reason and the VM is confirmed dead or never came up — inspect the transcript below, then reconcile or destroy this VM before re-adding.');
308
+ }
309
+
310
+ // 9b. verifying — the honest default. No failure signature, and nothing
311
+ // durable proves this VM is dead: it may still be provisioning or
312
+ // wiring, and the ingress can keep propagating for minutes after the
313
+ // wrapper is gone. Calm copy, transcript still one click away. NOT
314
+ // retryable — nothing is proven about the box either way, so a one-click
315
+ // retry could still duplicate a live GCP instance (the ADD_FAILURE_RETRYABLE
316
+ // grounds are unchanged by this inversion).
317
+ return buildFailureVerdict('verifying', 'Add outcome unconfirmed',
318
+ 'No failure signature was found in the transcript and nothing proves this VM is dead, so the add may still be converging — the ingress keeps propagating for minutes after the provision wrapper exits. Inspect the transcript below; reconcile this VM only if it is still unresolved past the wiring deadline.');
224
319
  }
225
320
 
226
321
  // Pure guard for the operator "Retry add" action: given a VM number and its
@@ -238,15 +333,29 @@ function canRetryAdd({ n, job } = {}) {
238
333
  }
239
334
 
240
335
  // Pure badge-hold predicate for deriveStatus (server.js): hold the calm in-flight
241
- // `add-converging` card — instead of the red add-failed — ONLY when the add was
242
- // classified `converging` AND its detached first-wiring is still in flight. Once
243
- // the wire-add-tail fails/deadlines (`addTailFailed`) or is gone (`addTailInflight`
244
- // false), or the category is anything else, return false so the caller falls
245
- // through to the red add-failed status — the operator-chosen escalation. Kept pure
246
- // (like canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
336
+ // card — instead of the red add-failed — while the add is still converging.
337
+ //
338
+ // The hold used to require `addTailInflight`, i.e. that the detached wire-add-tail
339
+ // PROCESS was still alive. That premise was wrong: the tail's exit says nothing
340
+ // about LB convergence, because the EXTERNAL_MANAGED backend services keep
341
+ // propagating for minutes afterwards. VM-4's backends were still being created at
342
+ // 18:17-18:20Z, long after its job had been declared failed at 18:15Z — the tail
343
+ // had exited, so the calm hold released and the card went red on a healthy VM.
344
+ //
345
+ // The hold is now driven by the question that actually matters: is this add still
346
+ // converging, and has its wiring deadline genuinely blown? `wiringDeadlineBlown`
347
+ // (the failed marker the tail drops when it gives up) stays the release signal —
348
+ // tail liveness is no longer a requirement for calm, so the calm state survives
349
+ // the multi-minute propagation window instead of ending at process exit.
350
+ //
351
+ // Both calm categories qualify (see CALM_ADD_CATEGORIES): a rostered box that is
352
+ // still wiring, and the unproven residue. Any proven-failure category returns
353
+ // false and falls through to the red add-failed status — the operator-chosen
354
+ // escalation, which this predicate narrows but never softens. Kept pure (like
355
+ // canRetryAdd / canClearBadge) so the badge decision is unit-tested without a
247
356
  // live dashboard.
248
- function holdConvergingBadge({ category, addTailInflight, addTailFailed } = {}) {
249
- return category === 'converging' && !!addTailInflight && !addTailFailed;
357
+ function holdConvergingBadge({ category, wiringDeadlineBlown } = {}) {
358
+ return CALM_ADD_CATEGORIES.has(category) && !wiringDeadlineBlown;
250
359
  }
251
360
 
252
- module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, ADD_FAILURE_RETRYABLE };
361
+ module.exports = { classifyAddFailure, extractDriftFiles, lastNonEmptyLines, canRetryAdd, transcriptRostered, holdConvergingBadge, addFailureCorroborated, containerHealthWitness, CALM_ADD_CATEGORIES, ADD_FAILURE_RETRYABLE };