@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.
- package/npm/cloud.js +192 -29
- package/npm/compose-conflict.js +59 -0
- package/npm/fleet-add-failure.js +123 -14
- package/npm/fleet-cleanup.js +129 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-memory.js +44 -0
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +37 -7
- package/npm/launch-config.js +120 -4
- package/npm/scenario-check.js +52 -18
- package/npm/scenario-playwright.js +44 -10
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-DgM0pTAx.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-BLqRCxQX.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-DCHqRrwO.js → index-VuyqBbGJ.js} +2 -2
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +0 -252
- package/ui/dist/assets/ProjectLauncherScreen-BRrEr8Ng.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +0 -9
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +0 -21
- package/ui/dist/assets/index-BygCiJSb.css +0 -1
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +0 -1
package/npm/fleet-cleanup.js
CHANGED
|
@@ -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
|
};
|
package/npm/fleet-job-badge.js
CHANGED
|
@@ -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
|
|
19
|
-
// reachable
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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 (
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
|
|
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;
|
package/npm/fleet-memory.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
95
|
+
feature: pick("feature"),
|
|
75
96
|
step: stepNum,
|
|
76
|
-
mode:
|
|
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
|
|