@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
@@ -337,6 +337,132 @@ function reattachNextStep(verdict) {
337
337
  return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
338
338
  }
339
339
 
340
+ // May the PERIODIC stranded-reconcile pass drive a recover/roster probe for this
341
+ // VM, or is a provision already running that a second cloud:up would collide
342
+ // with? This is the guard the boot re-attach path has always had and the
343
+ // stranded path never did — the 2026-08-07 asymmetry that destroyed VM-4/VM-5.
344
+ //
345
+ // On that day an `add` job badge read `error` while its detached `cloud:up`
346
+ // child was alive and healthy (a source-build bootstrap runs ~40 minutes;
347
+ // `npm install` alone took 25m). `pollStrandedInstances` builds `activeAddNs`
348
+ // from BADGE state and deliberately excludes an `error` add so the bounded
349
+ // bootstrap-recovery budget can retry on a timer — so the VM read
350
+ // running-but-unrostered, crossed STRANDED_ADOPT_THRESHOLD in ~3 minutes, and
351
+ // the pass launched a SECOND cloud:up on top of the live one. The two raced on
352
+ // `docker compose up -d`, the loser hit `Conflict. The container name
353
+ // "/codeyam-editor-editor-1" is already in use`, and its abort handler deleted
354
+ // the instance. The badge is a derived, lossy signal that was wrong for 30
355
+ // minutes straight; LIVENESS is the ground truth, so this gate reads exactly
356
+ // what `reattachAdd` reads — the exit marker plus a `kill -0` of the recorded
357
+ // pid — and composes `reattachVerdict`/`reattachNextStep` rather than forking a
358
+ // parallel matrix.
359
+ //
360
+ // 'watch' no marker + a live child → the provision is genuinely in flight.
361
+ // Re-attach and watch (mirroring
362
+ // runStartupReap); NEVER provision.
363
+ // 'defer' no marker, no live child, and no pid was EVER recorded, inside the
364
+ // ambiguity window → we cannot tell whether a child is
365
+ // running. Do nothing this tick.
366
+ // 'probe' everything else → existing behavior: drive the gcloud
367
+ // probe (reconcileViaGcloud →
368
+ // reapVerdict).
369
+ //
370
+ // The 'defer' rung exists because `childAlive` is FALSE-NEGATIVE-PRONE on one
371
+ // specific path: when `strandedReconcileMeta` finds no sidecar it reconstructs
372
+ // cfg from launch history and persists `{ cfg }` with NO pid, so `isPidAlive`
373
+ // reads false for a VM whose child may well be alive — and, worse, that pid-less
374
+ // sidecar is what every later tick reads back, blinding the guard permanently.
375
+ // `pidRecorded` separates "we knew the pid and the process is gone" (real
376
+ // evidence → probe) from "we never had a pid" (absence of evidence → defer).
377
+ // Erring toward NOT provisioning is the asymmetric-cost choice: waiting one more
378
+ // 60s tick costs nothing, being wrong costs a destroyed VM.
379
+ //
380
+ // The window is what keeps 'defer' from becoming a permanent refusal that would
381
+ // break stranded adoption outright: once more than `ambiguityWindowMs` has
382
+ // elapsed since we first saw the pid-less sidecar, no plausible provision child
383
+ // is still running, so the probe proceeds and the self-heal path is preserved.
384
+ // An unknown/unstamped elapsed is treated as "just noticed" (defer), again the
385
+ // safe direction; a caller that supplies no window at all opts out entirely and
386
+ // keeps the old probe-always behavior.
387
+ //
388
+ // Pure, so the whole matrix is testable without a VM: server.js maps the verdict
389
+ // to its side effect.
390
+ function strandedRecoverGate({
391
+ markerPresent,
392
+ exitCode,
393
+ childAlive,
394
+ pidRecorded,
395
+ sinceMs,
396
+ ambiguityWindowMs,
397
+ } = {}) {
398
+ // A live child is a live child — reuse the existing classifier so 'running'
399
+ // keeps meaning exactly what the boot path means by it.
400
+ if (reattachNextStep(reattachVerdict({ markerPresent, exitCode, childAlive })) === 'watch') {
401
+ return 'watch';
402
+ }
403
+ // A marker is durable proof the child reached its EXIT trap: nothing is in
404
+ // flight, so the probe is safe. (Which probe outcome follows — roster,
405
+ // recover-bootstrap, surface-error — stays reapVerdict's call, not ours.)
406
+ if (markerPresent) return 'probe';
407
+ // We knew the pid and `kill -0` says it is gone: real evidence, so probe.
408
+ if (pidRecorded) return 'probe';
409
+ const window = Number(ambiguityWindowMs);
410
+ if (!Number.isFinite(window) || window <= 0) return 'probe'; // caller opted out
411
+ const elapsed = Number(sinceMs);
412
+ if (!Number.isFinite(elapsed)) return 'defer'; // unstamped — treat as just-noticed
413
+ return elapsed < window ? 'defer' : 'probe';
414
+ }
415
+
416
+ // Is an `add` genuinely IN FLIGHT for this VM right now?
417
+ //
418
+ // Extracted from pollVanishedInstances, where the inline expression was
419
+ // `addPending.has(n) || !!(state.jobs[n] && state.jobs[n].action === 'add')` —
420
+ // whose second clause is also true for a TERMINAL (`done` / `error`) add job.
421
+ // That permanently marks the VM busy, so `reconcileRosteredVm` can never return
422
+ // 'cleanup' and a rostered VM whose instance is gone can never be auto-reaped.
423
+ // Live evidence from the same 2026-08-07 incident: VM-5's instance was deleted
424
+ // at ~12:46 and its card logged `vanish-reconcile: VM-5 unreachable …` once a
425
+ // minute for 50 consecutive polls, only clearing after a manual `clear-badge`
426
+ // deleted the terminal job record.
427
+ //
428
+ // The on-disk sidecar clause is already correct and is preserved: a sidecar is
429
+ // removed on the add's success path, so its presence really does mean in-flight.
430
+ // The job clause now filters on job STATE the same way `activeAddNs` and
431
+ // `pollStartupProbes` do. Pure so the caller's computation is directly
432
+ // assertable — the bug was never in `reconcileRosteredVm`'s contract, it was in
433
+ // what this expression fed it.
434
+ const IN_FLIGHT_ADD_STATES = ['running', 'queued', 'recovering'];
435
+
436
+ function hasInFlightAdd({ sidecarPending, job } = {}) {
437
+ if (sidecarPending) return true;
438
+ if (!job || job.action !== 'add') return false;
439
+ return IN_FLIGHT_ADD_STATES.includes(job.state);
440
+ }
441
+
442
+ // WHICH of reconcileRosteredVm's two distinct 'leave' causes fired, as a tag the
443
+ // caller renders. Pure so the distinction is directly assertable.
444
+ //
445
+ // 'leave' is returned both when the GCP probe was INCONCLUSIVE (exists null /
446
+ // undefined — absence of evidence, never act) and when the instance is
447
+ // CONFIRMED gone but the VM is busy. The old single log line conflated them into
448
+ // a self-contradiction — it announced "instance-gone unconfirmed (exists=false)"
449
+ // when exists=false IS the confirmation — which cost hours of code-reading
450
+ // during the 2026-08-07 incident to work out which branch was actually firing.
451
+ //
452
+ // 'probe-inconclusive' exists is null/undefined — retry next tick
453
+ // 'live-session' instance confirmed gone but a session is live
454
+ // 'in-flight-add' instance confirmed gone but an add is in flight
455
+ // null not a 'leave' case at all
456
+ //
457
+ // Mirrors reconcileRosteredVm's own precedence (exists-uncertainty first, then
458
+ // hasSession, then hasPendingAdd) so the tag can never disagree with the verdict.
459
+ function vanishLeaveReason({ exists, hasSession, hasPendingAdd } = {}) {
460
+ if (exists === null || exists === undefined) return 'probe-inconclusive';
461
+ if (hasSession) return 'live-session';
462
+ if (hasPendingAdd) return 'in-flight-add';
463
+ return null;
464
+ }
465
+
340
466
  // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
341
467
  // recoveries ALREADY persisted on the sidecar, return the next attempt number,
342
468
  // whether the reap should proceed (re-run cloud:up) or treat the VM as
@@ -537,4 +663,7 @@ module.exports = {
537
663
  isSignalKillExit,
538
664
  reattachVerdict,
539
665
  reattachNextStep,
666
+ strandedRecoverGate,
667
+ hasInFlightAdd,
668
+ vanishLeaveReason,
540
669
  };
@@ -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