@codeyam-editor/codeyam-editor 0.1.0-staging.g25ba0d1 → 0.1.0-staging.g296f8a2

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.
@@ -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 };
@@ -15,21 +15,37 @@
15
15
  // Should the VM's last terminal-error job badge be reconciled against live
16
16
  // health — i.e. presented as "recovered" rather than a live red error?
17
17
  //
18
- // job the carried job record ({ state, action, ... }) or null
19
- // reachable the VM's editor answered this poll (true = healthy / responding)
20
- // drifted a project-dir-drift signal is present (reachable-but-unwired /
21
- // half-provisioned add — genuinely needs-recovery, owned by the
22
- // companion reconcile-inflight-jobs plan; never falsely cleared here)
23
- // offBranch the VM's live client branch differs from its target branch
24
- // degraded the job completed but a wiring sub-step is flagged degraded
18
+ // job the carried job record ({ state, action, ... }) or null
19
+ // reachable the VM's editor answered this poll over the LB (true = healthy)
20
+ // inVmHealthy the in-VM editor answered /api/health from INSIDE the container
21
+ // (the STRAND_PROBE `HEALTH=1` witness gathered over docker-exec)
22
+ // drifted a project-dir-drift signal is present (reachable-but-unwired /
23
+ // half-provisioned add — genuinely needs-recovery, owned by the
24
+ // companion reconcile-inflight-jobs plan; never falsely cleared here)
25
+ // offBranch the VM's live client branch differs from its target branch
26
+ // degraded the job completed but a wiring sub-step is flagged degraded
25
27
  //
26
28
  // Returns true only when the job is a terminal error AND the VM is demonstrably
27
- // fine (reachable, not drifted, on-branch, not degraded). An unreachable /
28
- // drifted / off-branch VM keeps its error surfaced — a real problem must stay
29
- // visible.
30
- function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degraded } = {}) {
29
+ // fine (healthy, not drifted, on-branch, not degraded). A drifted / off-branch /
30
+ // unwitnessed VM keeps its error surfaced — a real problem must stay visible.
31
+ //
32
+ // HEALTH IS WITNESSED TWO WAYS, and either suffices. `reachable` alone made the
33
+ // self-heal unable to fire in exactly the case that needs it most: on the GCE
34
+ // operator host `reachable` is derived from the LB probe, which needs the very
35
+ // ingress the add is still wiring. That is a circular dependency — the badge
36
+ // needs reachable, reachable needs ingress, and ingress is what we are waiting
37
+ // for — so a still-wiring VM sat at unreachableStreak 9 with a red add-error it
38
+ // could not clear by construction (VM-4, 2026-07-31). The dashboard already held
39
+ // the disconfirming evidence: gatherLauncherStrand runs STRAND_PROBE over
40
+ // docker-exec for exactly these unreachable-with-streak VMs and reads HEALTH=1
41
+ // from the in-VM /api/health.
42
+ //
43
+ // This widens ONE input; it does not relax the verdict. Every other guard is
44
+ // untouched, and the "absence of evidence never auto-acts" posture holds — an
45
+ // unprobed VM has neither witness and stays unreconciled.
46
+ function shouldReconcileFailedBadge({ job, reachable, inVmHealthy, drifted, offBranch, degraded } = {}) {
31
47
  if (!job || job.state !== 'error') return false;
32
- if (!reachable) return false;
48
+ if (!reachable && !inVmHealthy) return false;
33
49
  if (drifted) return false;
34
50
  if (offBranch) return false;
35
51
  if (degraded) return false;
@@ -356,6 +356,49 @@ function shouldAnnounceMemoryLevel({ level, lastLevel } = {}) {
356
356
  return !QUIET_MEMORY_LEVELS.includes(level);
357
357
  }
358
358
 
359
+ /** Resolve the directory holding THIS process's memory.current / memory.high,
360
+ * or null when no cgroup-derived reading is available.
361
+ *
362
+ * The cgroup-v2 hierarchy ROOT exposes no memory.current and no memory.high —
363
+ * only a leaf cgroup does. Reading `/sys/fs/cgroup/memory.current` directly is
364
+ * therefore ENOENT on every systemd host, and that is exactly what made the
365
+ * 2026-07-30 memory remedy inert: both reads failed, every cgroup number
366
+ * degraded to null, decideMemoryRestart answered 'no-cgroup-limit' on every
367
+ * tick forever, and the drain silently fell back to the RSS threshold the
368
+ * incident had already proven cannot fire before the event loop stalls.
369
+ *
370
+ * Takes the CONTENTS of /proc/self/cgroup rather than reading it, so the path
371
+ * derivation — the part that was wrong — is unit-testable on a macOS dev box
372
+ * with no cgroup filesystem at all. That untestability is why a root-vs-leaf
373
+ * path error shipped green.
374
+ *
375
+ * Returns null for every shape that has no leaf memory file to read:
376
+ * • the unified line `0::/` — the process IS in the root cgroup, which
377
+ * genuinely has no memory files; this must NOT look like a valid path;
378
+ * • cgroup v1 (`N:controller:/path` lines, no `0::` entry);
379
+ * • empty, unreadable, or malformed input (a non-Linux host has no
380
+ * /proc/self/cgroup at all, and the caller passes the null through).
381
+ *
382
+ * Pure; no I/O. */
383
+ function resolveCgroupMemoryDir({ procSelfCgroup, root } = {}) {
384
+ if (typeof procSelfCgroup !== 'string') return null;
385
+ const base = (typeof root === 'string' && root)
386
+ ? root.replace(/\/+$/, '')
387
+ : '/sys/fs/cgroup';
388
+ for (const line of procSelfCgroup.split('\n')) {
389
+ const trimmed = line.trim();
390
+ // cgroup v2 unified hierarchy is the single `0::<path>` entry. v1 lines
391
+ // (`N:controller:/path`) never match, which is the intended null path.
392
+ if (!trimmed.startsWith('0::')) continue;
393
+ const rel = trimmed.slice(3);
394
+ if (!rel.startsWith('/')) return null;
395
+ const clean = rel.replace(/\/+$/, '');
396
+ if (!clean) return null;
397
+ return base + clean;
398
+ }
399
+ return null;
400
+ }
401
+
359
402
  /** Keep only the restart timestamps still inside the rolling window.
360
403
  *
361
404
  * Split out from decideMemoryRestart so the "how many restarts have I already
@@ -386,6 +429,7 @@ module.exports = {
386
429
  formatMemoryText,
387
430
  hasJobInFlight,
388
431
  shouldAnnounceMemoryLevel,
432
+ resolveCgroupMemoryDir,
389
433
  pruneRestartWindow,
390
434
  buildStateSnapshot,
391
435
  decodeStateSnapshot,
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+ // The fleet dashboard's two LB-path HTTP probe transports, extracted from
3
+ // scripts/operator/fleet-dashboard/server.js so their SOCKET LIFETIME is
4
+ // testable.
5
+ //
6
+ // The undici contract, which is the whole reason this module exists: a `fetch`
7
+ // response body is a stream that holds its connection open until it is read to
8
+ // completion or explicitly cancelled. IGNORING `res.body` is NOT the same as
9
+ // releasing it — the socket stays checked out of the agent's pool, and once the
10
+ // peer sends FIN it parks in CLOSE-WAIT forever. Node's `http` module is the
11
+ // opposite (an unread response pauses the socket, hence the `res.resume(); //
12
+ // drain so the socket can close` in server.js's localGetJson), and that
13
+ // asymmetry is what got missed when the fetch path was added.
14
+ //
15
+ // On 2026-08-01 that leaked 10,749 CLOSE-WAIT sockets to the fleet LB
16
+ // (8.228.227.92:443) holding 128MB of unread IAP-login HTML in kernel receive
17
+ // queues — ~460 sockets/hour, a ~1-day fuse — driving the dashboard cgroup into
18
+ // swap-thrash until the node event loop stalled in `D` state and dashboard-bes
19
+ // went UNHEALTHY while systemd still reported the unit active.
20
+ //
21
+ // So: EVERY return path here consumes or cancels the body. `fetch` is injected
22
+ // rather than closed over, so a test can drive every branch with a body that
23
+ // records whether it was cancelled.
24
+
25
+ const fpt = require('./fleet-vm-probe-target');
26
+
27
+ /** Release the connection behind a response we are not going to read.
28
+ *
29
+ * Swallows its own failure deliberately. `cancel()` rejects when the body was
30
+ * already consumed or already errored — in both cases the connection is
31
+ * released anyway, so there is nothing to recover from and nothing to report.
32
+ * Letting it throw would be actively harmful: these calls sit inside the
33
+ * transports' `try`, so a rejection would be caught by the transport's own
34
+ * catch and misclassify a perfectly good HTTP response as `unreachable`.
35
+ */
36
+ async function disposeBody(res) {
37
+ try {
38
+ await res.body?.cancel();
39
+ } catch {
40
+ // Already consumed, already errored, or no body at all — connection is
41
+ // released either way.
42
+ }
43
+ }
44
+
45
+ /** GET JSON from a resolved LB target.
46
+ *
47
+ * Uses redirect:'manual' so IAP's 302→accounts.google.com surfaces as a
48
+ * classifiable response instead of the fetch silently following it and handing
49
+ * us a login page that fails JSON parsing — which would read as "VM is down"
50
+ * when the real fault is our token. That 302 carries a full HTML login page,
51
+ * which is exactly why the non-ok return below must dispose of the body.
52
+ *
53
+ * `deps` injects the transport's collaborators for tests: `fetchImpl` (default
54
+ * global fetch) and `warn` (default console.warn).
55
+ */
56
+ async function lbGetJson(target, timeout, deps = {}) {
57
+ const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
58
+ const ctl = new AbortController();
59
+ const timer = setTimeout(() => ctl.abort(), timeout);
60
+ try {
61
+ const res = await fetchImpl(target.url, {
62
+ headers: target.headers,
63
+ redirect: 'manual',
64
+ signal: ctl.signal,
65
+ });
66
+ const outcome = fpt.classifyProbeResponse({
67
+ status: res.status,
68
+ location: res.headers.get('location'),
69
+ });
70
+ if (outcome !== 'ok') {
71
+ // 401 and 403 share one outcome but have opposite fixes (OAuth client vs
72
+ // IAM grant). Name which one we hit, so the next misconfig is diagnosable
73
+ // from the log instead of a live curl against the backend.
74
+ const why = fpt.describeAuthMisconfig(res.status);
75
+ if (why) warn(`probe ${target.url}: HTTP ${res.status} — ${why}`);
76
+ await disposeBody(res);
77
+ return { outcome, json: null, status: res.status };
78
+ }
79
+ // `status` rides on every return so a caller can NAME the HTTP status in a
80
+ // fault warning. Without it a reachable-but-unusable body was indiscernible
81
+ // from a dead VM in the log — see probeBodyFault.
82
+ //
83
+ // Both branches below are body-safe without an explicit cancel: `res.text()`
84
+ // reads the stream to completion, so the connection is already released by
85
+ // the time JSON.parse runs or throws.
86
+ try { return { outcome: 'ok', json: JSON.parse(await res.text()), status: res.status }; }
87
+ catch { return { outcome: 'unreachable', json: null, error: 'bad-json', status: res.status }; }
88
+ } catch (err) {
89
+ // No response to dispose of — the request never produced one.
90
+ return {
91
+ outcome: 'unreachable',
92
+ json: null,
93
+ error: err && err.name === 'AbortError' ? 'timeout' : 'error',
94
+ };
95
+ } finally {
96
+ clearTimeout(timer);
97
+ }
98
+ }
99
+
100
+ /** Reachability-only transport for the PUBLIC preview ingress over the IAP-LB
101
+ * seam.
102
+ *
103
+ * Unlike lbGetJson it does not PARSE a body — the preview root returns HTML, so
104
+ * routing it through lbGetJson would fail-close on the non-JSON parse. It must
105
+ * still DISPOSE of that body on every path; "does not parse" was historically
106
+ * written as "does not touch", and that is precisely what leaked.
107
+ *
108
+ * Classifies purely on the HTTP response:
109
+ * any non-auth HTTP response (200, or even 404/502 — the LB routed to a
110
+ * backend that answered) → true (ingress wired);
111
+ * auth-misconfigured (302→Google login / 401 / 403) → null (UNKNOWN, never
112
+ * false): the preview backend is a SEPARATE IAP backend
113
+ * service whose audience/accessor may differ from the
114
+ * editor backend the operator token is minted for, so an
115
+ * auth challenge here proves nothing about the URL wiring;
116
+ * transport error / timeout → false (nothing answered).
117
+ *
118
+ * Uses redirect:'manual' so an IAP login 302 surfaces as classifiable instead
119
+ * of fetch silently following it to a login page.
120
+ */
121
+ async function lbProbeReachable(target, timeout, deps = {}) {
122
+ const { fetchImpl = globalThis.fetch, warn = console.warn } = deps;
123
+ const ctl = new AbortController();
124
+ const timer = setTimeout(() => ctl.abort(), timeout);
125
+ try {
126
+ const res = await fetchImpl(target.url, {
127
+ headers: target.headers,
128
+ redirect: 'manual',
129
+ signal: ctl.signal,
130
+ });
131
+ const outcome = fpt.classifyProbeResponse({
132
+ status: res.status,
133
+ location: res.headers.get('location'),
134
+ });
135
+ if (outcome === 'auth-misconfigured') {
136
+ const why = fpt.describeAuthMisconfig(res.status);
137
+ if (why) warn(`preview ingress ${target.url}: HTTP ${res.status} — ${why}`);
138
+ await disposeBody(res);
139
+ return null;
140
+ }
141
+ await disposeBody(res);
142
+ return true;
143
+ } catch {
144
+ // No response to dispose of — the request never produced one.
145
+ return false;
146
+ } finally {
147
+ clearTimeout(timer);
148
+ }
149
+ }
150
+
151
+ module.exports = {
152
+ disposeBody,
153
+ lbGetJson,
154
+ lbProbeReachable,
155
+ };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+ // Pure helpers for the fleet dashboard's DEPLOYED-REVISION staleness signal
3
+ // (scripts/operator/fleet-dashboard/server.js requires this; unit tests in
4
+ // npm/fleet-revision.test.js).
5
+ //
6
+ // The dashboard header has always shown the branch name, which says nothing
7
+ // about WHICH COMMIT of that branch is serving. On 2026-07-31 the operator
8
+ // host's /opt/codeyam-editor sat 104 commits behind origin/editor-improvements-66,
9
+ // so the deployed dashboard predated its own memory fix and was
10
+ // indistinguishable from one at tip — the third recurrence of a landed fix
11
+ // surviving because the served checkout was behind (the June VM-5 slug bug was
12
+ // the same shape).
13
+ //
14
+ // server.js owns the I/O — the bounded `git fetch` / `rev-parse` / `rev-list`
15
+ // children and the slow-cycle memo. The two rules that actually matter are here
16
+ // and are pure: how a failed check merges over the last good one, and what
17
+ // verdict a given count means. Both were previously inline in an async IIFE
18
+ // that no test could reach.
19
+
20
+ /** Merge one revision-check result over the previous memo.
21
+ *
22
+ * THE RULE: a failed check RETAINS the previous `behind` and records `error` —
23
+ * it never reports zero. This is what makes the staleness chip sticky. A
24
+ * broken `git fetch` that reset the count to 0 would present a stale dashboard
25
+ * as current, which is precisely the failure this whole signal exists to catch;
26
+ * degrading to "last known count, plus a note that the check is failing" is
27
+ * the honest answer.
28
+ *
29
+ * A `behind` that is absent, negative, or unparseable counts as no reading and
30
+ * falls back to the previous value the same way. `sha` and `branch` likewise
31
+ * retain their last known values rather than blanking the header on a hiccup.
32
+ *
33
+ * A previous `behind` of null (never successfully counted) stays null — it is
34
+ * deliberately NOT coerced through Number(), because `Number(null) === 0` is
35
+ * the exact coercion that made "no reading" and "zero" indistinguishable in
36
+ * readCgroupMemory.
37
+ *
38
+ * Pure; no I/O. */
39
+ function mergeDashboardRevision({ prev, sha, branch, behind, checkedAt, error } = {}) {
40
+ const base = (prev && typeof prev === 'object') ? prev : {};
41
+ const str = (next, fallback) => ((typeof next === 'string' && next) ? next : (typeof fallback === 'string' && fallback ? fallback : null));
42
+ // Strictly a number: a string or null is NOT coerced, so a caller cannot
43
+ // accidentally turn "no reading" into 0 the way Number(null) would.
44
+ const count = (v) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null);
45
+ const counted = count(behind);
46
+ return {
47
+ sha: str(sha, base.sha),
48
+ branch: str(branch, base.branch),
49
+ behind: counted === null ? count(base.behind) : counted,
50
+ checkedAt: str(checkedAt, base.checkedAt),
51
+ error: str(error, null),
52
+ };
53
+ }
54
+
55
+ /** What does the current revision memo mean for the operator?
56
+ *
57
+ * Split out from the header chip so the verdict is a readable table rather
58
+ * than branches buried in string concatenation — the same reason
59
+ * operatorAuthRecovery was split out of operatorAuthBannerHtml, and the same
60
+ * reason resolveCgroupMemoryDir was split out of readCgroupMemory: logic that
61
+ * can only be reached through a browser inline script or a real cgroup
62
+ * filesystem is logic that ships its bugs green.
63
+ *
64
+ * Levels:
65
+ * 'stale' — behind > 0. Landed fixes are not live; the chip warns.
66
+ * 'current' — behind === 0. Clears the chip, and clears it even when the
67
+ * latest check errored: the count reaching zero is the ONLY
68
+ * thing that clears the warning, and once it has, a later
69
+ * failed fetch has no stale count to keep showing.
70
+ * 'unknown' — no count has ever succeeded AND the check is failing. Worth
71
+ * saying out loud rather than rendering the reassuring nothing
72
+ * that an at-tip dashboard renders.
73
+ * 'unchecked' — no count yet and nothing has failed (the boot window before
74
+ * the first slow-cycle refresh). Renders nothing.
75
+ *
76
+ * Pure; no I/O. */
77
+ function classifyDashboardStaleness({ behind, error } = {}) {
78
+ const counted = (typeof behind === 'number' && Number.isFinite(behind) && behind >= 0) ? behind : null;
79
+ if (counted === null) return error ? 'unknown' : 'unchecked';
80
+ return counted > 0 ? 'stale' : 'current';
81
+ }
82
+
83
+ /** The `/data` view of the revision memo, with the staleness VERDICT resolved
84
+ * server-side.
85
+ *
86
+ * The chip is then a pure renderer: `public/index.html` is an inline browser
87
+ * script with no module boundary, so any decision left in it could only ever
88
+ * be source-scan tested — assertions on text rather than on behavior.
89
+ *
90
+ * Lives here rather than in `server.js` for the same reason
91
+ * `classifyDashboardStaleness` does: `server.js` has top-level side effects on
92
+ * require (createServer/listen/setInterval), so nothing there can be executed
93
+ * by a test and a projection defined inside it ships its bugs green.
94
+ *
95
+ * Pure; no I/O. */
96
+ function projectDashboardRevision(memo = {}) {
97
+ return { ...memo, level: classifyDashboardStaleness(memo) };
98
+ }
99
+
100
+ module.exports = {
101
+ mergeDashboardRevision,
102
+ classifyDashboardStaleness,
103
+ projectDashboardRevision,
104
+ };
@@ -1,8 +1,15 @@
1
1
  'use strict';
2
2
 
3
3
  // Resolve the running-plan fields a fleet card shows — feature, step, mode,
4
- // selectedPlan, holderSessionId — from the two independent per-VM probes plus
5
- // the last-known-good state.
4
+ // selectedPlan, holderSessionId, startedAt, featureStartedAt — from the two
5
+ // independent per-VM probes plus the last-known-good state.
6
+ //
7
+ // The two timestamps ride here because they share the restart problem below and
8
+ // nothing else resolves them: `startedAt` / `featureStartedAt` drive the card's
9
+ // "on step" / "on feature" tenure badges AND the server-side slow/stuck/needs-you
10
+ // verdict (fleet-stuck-status.js deriveAttention keys on `startedAt`). A null
11
+ // degrades that verdict to level:'ok', needsYou:false — so a VM whose editor
12
+ // restarted was silently exempt from the very detection it most needed.
6
13
  //
7
14
  // Why this exists: the card used to hang these fields off `/api/session-info`
8
15
  // alone (spread as `...info` in server.js). But a VM whose editor server
@@ -35,10 +42,11 @@
35
42
  // @param {?Object} params.info Parsed `/api/session-info` body (carries
36
43
  // `reachable`), or null on a failed/timed-out probe.
37
44
  // @param {?Object} params.prev The previous `state.vms[n]` (carries the
38
- // last-known feature/step/mode/selectedPlan/holderSessionId). null/undefined
39
- // on the first poll.
45
+ // last-known feature/step/mode/selectedPlan/holderSessionId/startedAt/
46
+ // featureStartedAt). null/undefined on the first poll.
40
47
  // @returns {{feature: ?string, step: ?number, mode: ?string,
41
- // selectedPlan: ?string, holderSessionId: ?string}}
48
+ // selectedPlan: ?string, holderSessionId: ?string, startedAt: ?string,
49
+ // featureStartedAt: ?string}}
42
50
  function resolveRunningPlan({ step, info, prev } = {}) {
43
51
  const s = step || {};
44
52
  const i = info || {};
@@ -56,6 +64,10 @@ function resolveRunningPlan({ step, info, prev } = {}) {
56
64
  mode: p.mode || null,
57
65
  selectedPlan: p.selectedPlan || null,
58
66
  holderSessionId: p.holderSessionId || null,
67
+ // Carried verbatim like the rest: a single timed-out cycle must not blink
68
+ // the tenure badges off and reset the stuck timer back to zero.
69
+ startedAt: p.startedAt || null,
70
+ featureStartedAt: p.featureStartedAt || null,
59
71
  };
60
72
  }
61
73
 
@@ -67,18 +79,36 @@ function resolveRunningPlan({ step, info, prev } = {}) {
67
79
  const samePlan = freshSelectedPlan != null && freshSelectedPlan === (p.selectedPlan || null);
68
80
  const carry = samePlan ? p : {};
69
81
 
82
+ // The module's precedence rule, in ONE place: /api/step (session-independent)
83
+ // > /api/session-info > same-plan carry. Four fields resolve exactly this way,
84
+ // so hoisting it keeps a future precedence change from being applied to some
85
+ // of them and not others. The two fields that deviate stay longhand below, and
86
+ // their being longhand is the signal that they deviate: `step` needs an
87
+ // explicit null check (0 is a real step number, and `||` would swallow it),
88
+ // and holderSessionId deliberately skips session-info, which never carries it.
89
+ const pick = (key) => s[key] || i[key] || carry[key] || null;
90
+
70
91
  const stepNum =
71
92
  s.step != null ? s.step : i.step != null ? i.step : carry.step != null ? carry.step : null;
72
93
 
73
94
  return {
74
- feature: s.feature || i.feature || carry.feature || null,
95
+ feature: pick("feature"),
75
96
  step: stepNum,
76
- mode: s.mode || i.mode || carry.mode || null,
97
+ mode: pick("mode"),
77
98
  selectedPlan: freshSelectedPlan || carry.selectedPlan || null,
78
99
  // session-info never carries holderSessionId — it comes from /api/step
79
100
  // (editor-step.json) or the same-plan carry. This is what lets a claim map
80
101
  // to the executing VM after an editor restart drops the live sessionId.
81
102
  holderSessionId: s.holderSessionId || carry.holderSessionId || null,
103
+ // Workflow tenure timers. /api/step wins because EditorStepState serializes
104
+ // `started_at` (non-optional, camelCase) straight out of editor-step.json —
105
+ // session-independent, so it survives the editor restart that makes
106
+ // session-info report hasSession:false and omit them entirely. The same-plan
107
+ // `carry` applies unchanged: a completed plan's startedAt must never be
108
+ // pinned onto the next plan, or the badges would show a tenure the new plan
109
+ // has not accrued.
110
+ startedAt: pick("startedAt"),
111
+ featureStartedAt: pick("featureStartedAt"),
82
112
  };
83
113
  }
84
114