@codeyam-editor/codeyam-editor 0.1.5 → 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 (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -20,38 +20,167 @@ const MEMORY_DEFAULTS = Object.freeze({
20
20
  drainCheckIntervalMs: 5000,
21
21
  snapshotIntervalMs: 30_000,
22
22
  snapshotMaxAgeMs: 5 * 60 * 1000,
23
+ // Fraction of the cgroup's own memory.high at which the drain engages. A
24
+ // FRACTION, not an absolute MB, because the absolute number silently
25
+ // desynchronizes from the unit file: codeyam-dashboard.service was raised
26
+ // from MemoryHigh=1.5G to 2.5G on 2026-07-28, while the operator host kept
27
+ // throttling at 1536MB — a hardcoded threshold cannot track that, a fraction
28
+ // of the value the kernel actually reports does. 0.80 of the 1536MB limit in
29
+ // force during the 2026-07-30 wedge is 1229MB, comfortably ahead of the
30
+ // 1309MB (85%) geometry at which the event loop had already stalled.
31
+ drainHighFraction: 0.80,
32
+ // Fraction of memory.high at which the drain is no longer enough and the
33
+ // process restarts itself to actually reclaim. Sits in a deliberately narrow
34
+ // band ABOVE the drain and BELOW the measured stall: 0.83 of 1536MB is
35
+ // 1275MB, ~34MB before the 1309MB geometry that wedged the dashboard. The
36
+ // band is tight because the evidence leaves little room — reclaim was already
37
+ // continuous at 85%.
38
+ restartHighFraction: 0.83,
39
+ // Crash-loop guard for the self-restart remedy. Counted over a rolling hour
40
+ // and persisted across restarts by the caller (an in-memory counter would
41
+ // reset on every restart and so could never limit anything).
42
+ maxRestartsPerHour: 3,
43
+ restartWindowMs: 60 * 60 * 1000,
23
44
  });
24
45
 
25
46
  /** Should the dashboard refuse to admit new heavy jobs?
26
47
  *
27
- * Returns true when current RSS exceeds the soft drain threshold. A non-
28
- * positive or non-finite threshold disables the drain (returns false) — a
29
- * misconfigured env var degrades to "no drain" instead of silently rejecting
30
- * every heavy job. Bytes on both sides so the caller doesn't have to pick a
31
- * unit; server.js passes MB→bytes once at module init.
48
+ * Decides from the CGROUP total when a cgroup reading is available, and only
49
+ * falls back to this process's RSS when it is not.
32
50
  *
33
- * Pure; no I/O. */
34
- function decideMemoryDrain({ rssBytes, drainThresholdBytes } = {}) {
35
- const rss = Number(rssBytes);
51
+ * Why the cgroup and not RSS: RSS is the wrong layer. The kernel throttles on
52
+ * the cgroup's memory.current against memory.high, and the cgroup runs
53
+ * 100-230MB ABOVE this process's RSS (109MB fresh, 226MB wedged, measured
54
+ * 2026-07-30). Sampling RSS therefore under-reads the condition by design —
55
+ * readCgroupMemory() in server.js has said so in a comment since improve37,
56
+ * but the lesson was only ever applied to operatorFaultVerdict, never carried
57
+ * back here. During the 2026-07-30 wedge RSS read 1083MB against a 1500MB
58
+ * threshold while the cgroup sat at 1309MB of a 1536MB limit, already emitting
59
+ * 42546 reclaim events: the drain could not fire before the stall, and no
60
+ * [memory] line was ever logged across the whole leak-to-wedge cycle. No RSS
61
+ * number fixes that, because the RSS-to-cgroup gap is itself variable.
62
+ *
63
+ * Contracts preserved exactly from the RSS-only version:
64
+ * • strict greater-than at the threshold, so a reading pinned at the limit
65
+ * cannot oscillate the drain on and off each tick;
66
+ * • a non-positive or non-finite drainThresholdBytes disables the drain
67
+ * entirely and returns false. That is the documented operator escape
68
+ * hatch (MEMORY_DRAIN_THRESHOLD_MB=0) and it is checked FIRST, so it
69
+ * disables the cgroup path too, not just the RSS one;
70
+ * • any unusable input degrades to "no drain", never to "always drain" — a
71
+ * dev laptop with no cgroup must not self-throttle.
72
+ *
73
+ * memoryHigh of null/0 means an unlimited cgroup (memory.high == 'max') or no
74
+ * cgroup at all (macOS, cgroup v1, a container without cgroup v2). Either way
75
+ * it contributes no pressure signal and the RSS fallback decides.
76
+ *
77
+ * Pure; no I/O — the caller supplies the numbers, as it always has. */
78
+ function decideMemoryDrain({
79
+ rssBytes,
80
+ drainThresholdBytes,
81
+ memoryCurrent,
82
+ memoryHigh,
83
+ drainHighFraction,
84
+ } = {}) {
36
85
  const cap = Number(drainThresholdBytes);
37
86
  if (!Number.isFinite(cap) || cap <= 0) return false;
87
+
88
+ // Preferred path: the quantity the kernel actually limits.
89
+ const current = Number(memoryCurrent);
90
+ const high = Number(memoryHigh);
91
+ const asked = Number(drainHighFraction);
92
+ const fraction = (Number.isFinite(asked) && asked > 0)
93
+ ? asked
94
+ : MEMORY_DEFAULTS.drainHighFraction;
95
+ if (Number.isFinite(current) && current >= 0 && Number.isFinite(high) && high > 0) {
96
+ return current > high * fraction;
97
+ }
98
+
99
+ // Documented fallback: no cgroup reading available, so keep the pre-existing
100
+ // RSS behavior rather than losing the drain entirely off-cgroup.
101
+ const rss = Number(rssBytes);
38
102
  if (!Number.isFinite(rss) || rss < 0) return false;
39
103
  return rss > cap;
40
104
  }
41
105
 
106
+ /** Should the dashboard restart itself to reclaim memory?
107
+ *
108
+ * The drain is an admission brake: it refuses NEW heavy jobs. On an idle-but-
109
+ * leaking process it changes nothing — during the 2026-07-30 wedge no job had
110
+ * run since Jul 28, so even a correctly-firing drain would not have helped.
111
+ * Something has to actually reclaim, and a restart is cheap here: systemd's
112
+ * Restart=always brings the process straight back and flushStateSnapshot has
113
+ * already persisted {jobs, vms, orphans} within the last 30s for
114
+ * restoreFromSnapshot to rehydrate.
115
+ *
116
+ * Returns { restart, reason } rather than a bare boolean so the caller can log
117
+ * WHY it declined — silence was the original failure mode, where the
118
+ * operator's first signal was an apparently-dead fleet.
119
+ *
120
+ * Refuses, in order:
121
+ * • the drain escape hatch is set (drainThresholdBytes <= 0) — disabling the
122
+ * drain disables the whole memory remedy, including this;
123
+ * • no usable cgroup reading — an off-cgroup dev laptop never self-restarts;
124
+ * • below the high-water fraction;
125
+ * • a job is in flight — a restart mid-provision would strand it;
126
+ * • the rolling-window restart budget is spent, which is the crash-loop guard.
127
+ *
128
+ * restartsInWindow is supplied by the caller because it must be PERSISTED: an
129
+ * in-memory counter resets on the very restart it is meant to be limiting.
130
+ *
131
+ * Pure; no I/O. */
132
+ function decideMemoryRestart({
133
+ memoryCurrent,
134
+ memoryHigh,
135
+ restartHighFraction,
136
+ drainThresholdBytes,
137
+ jobInFlight,
138
+ restartsInWindow,
139
+ maxRestartsPerWindow,
140
+ } = {}) {
141
+ const cap = Number(drainThresholdBytes);
142
+ if (!Number.isFinite(cap) || cap <= 0) return { restart: false, reason: 'memory-remedy-disabled' };
143
+
144
+ const current = Number(memoryCurrent);
145
+ const high = Number(memoryHigh);
146
+ if (!Number.isFinite(current) || current < 0) return { restart: false, reason: 'no-cgroup-reading' };
147
+ if (!Number.isFinite(high) || high <= 0) return { restart: false, reason: 'no-cgroup-limit' };
148
+
149
+ const asked = Number(restartHighFraction);
150
+ const fraction = (Number.isFinite(asked) && asked > 0)
151
+ ? asked
152
+ : MEMORY_DEFAULTS.restartHighFraction;
153
+ if (!(current > high * fraction)) return { restart: false, reason: 'below-high-water' };
154
+
155
+ if (jobInFlight) return { restart: false, reason: 'job-in-flight' };
156
+
157
+ const askedMax = Number(maxRestartsPerWindow);
158
+ const max = Number.isFinite(askedMax) ? askedMax : MEMORY_DEFAULTS.maxRestartsPerHour;
159
+ if (max <= 0) return { restart: false, reason: 'self-restart-disabled' };
160
+ const used = Number(restartsInWindow);
161
+ if (Number.isFinite(used) && used >= max) return { restart: false, reason: 'rate-limited' };
162
+
163
+ return { restart: true, reason: 'over-high-water' };
164
+ }
165
+
42
166
  /** Build the serialized state snapshot envelope.
43
167
  *
44
168
  * Keeps only the fields the dashboard needs to recover after a launchd
45
169
  * respawn: in-flight jobs, last VM poll merge, operator-visible orphans.
46
170
  * Times come from the caller so the same input yields the same output
47
171
  * (snapshot equality testable). Pure; no I/O. */
48
- function buildStateSnapshot({ jobs, vms, orphans, nowIso }) {
172
+ function buildStateSnapshot({ jobs, vms, orphans, orphanedIngress, nowIso }) {
49
173
  return {
50
174
  version: 1,
51
175
  timestamp: nowIso,
52
176
  jobs: jobs || {},
53
177
  vms: vms || {},
54
178
  orphans: orphans || {},
179
+ // Fleet-level orphaned-LB-ingress attention items keyed by destroyed VM
180
+ // number (vm--destroy-fully-tears-down-lb-ingress). Persisted so a genuine
181
+ // orphan survives a dashboard restart — the VM is gone, so it can't be
182
+ // re-derived from live poll state the way jobs/vms are.
183
+ orphanedIngress: orphanedIngress || {},
55
184
  };
56
185
  }
57
186
 
@@ -145,12 +274,163 @@ function restoreFromSnapshot({ snapshot, maxAgeMs, nowIso, sidecarKeys } = {}) {
145
274
  jobs,
146
275
  vms: (snapshot.vms && typeof snapshot.vms === 'object') ? snapshot.vms : {},
147
276
  orphans: (snapshot.orphans && typeof snapshot.orphans === 'object') ? snapshot.orphans : {},
277
+ orphanedIngress: (snapshot.orphanedIngress && typeof snapshot.orphanedIngress === 'object')
278
+ ? snapshot.orphanedIngress : {},
148
279
  };
149
280
  }
150
281
 
282
+ /** Render the three memory layers as one line.
283
+ *
284
+ * Deliberately reports RSS, the cgroup total, AND the gap between them
285
+ * together. Reading any one of those alone is how the drain sat on the wrong
286
+ * layer for so long: RSS said 1083MB and looked healthy against a 1500MB
287
+ * threshold while the cgroup was at 1309MB of 1536MB and already in continuous
288
+ * reclaim. The gap is not noise, it is the diagnostic.
289
+ *
290
+ * Degrades to 'cgroup=unavailable' rather than printing zeros off-cgroup, so a
291
+ * laptop log cannot be misread as a cgroup at 0MB.
292
+ *
293
+ * Pure; no I/O. */
294
+ function formatMemoryText({ rssBytes, memoryCurrent, memoryHigh } = {}) {
295
+ const mb = (b) => (Number(b) / 1024 / 1024).toFixed(0);
296
+ const current = Number(memoryCurrent);
297
+ const high = Number(memoryHigh);
298
+ const parts = [`RSS=${Number.isFinite(Number(rssBytes)) ? mb(rssBytes) : '?'}MB`];
299
+ if (Number.isFinite(current) && current > 0) {
300
+ parts.push(`cgroup=${mb(current)}MB`);
301
+ if (Number.isFinite(high) && high > 0) {
302
+ parts.push(`high=${mb(high)}MB`);
303
+ parts.push(`used=${((current / high) * 100).toFixed(0)}%`);
304
+ if (Number.isFinite(Number(rssBytes))) parts.push(`gap=${mb(current - Number(rssBytes))}MB`);
305
+ } else {
306
+ parts.push('high=unlimited');
307
+ }
308
+ } else {
309
+ parts.push('cgroup=unavailable');
310
+ }
311
+ return parts.join(' ');
312
+ }
313
+
314
+ /** Is any job doing real work right now?
315
+ *
316
+ * Gates the self-restart: reclaiming memory by exiting is cheap when the fleet
317
+ * is quiet and expensive mid-provision, where it would strand the job.
318
+ *
319
+ * `recovering` counts as in flight even though nothing is executing — the
320
+ * startup reap has not yet resolved it against gcloud, and restarting again
321
+ * would only defer it a second time. `queued` does NOT count: restoreFromSnapshot
322
+ * drops queued jobs on restore anyway, and a queued job has done no work worth
323
+ * protecting. The dashboard's own self-update counts, being a heavy job by any
324
+ * other name.
325
+ *
326
+ * Pure; no I/O — the caller passes state.jobs / state.dashboardUpdate. */
327
+ function hasJobInFlight({ jobs, dashboardUpdate } = {}) {
328
+ const busy = (j) => !!j && (j.state === 'running' || j.state === 'recovering');
329
+ const anyJob = Object.values((jobs && typeof jobs === 'object') ? jobs : {}).some(busy);
330
+ return anyJob || !!(dashboardUpdate && dashboardUpdate.state === 'running');
331
+ }
332
+
333
+ /** Should this escalation level be announced to the journal?
334
+ *
335
+ * Announce on a genuine level TRANSITION only. The check runs every 5s, so
336
+ * announcing unconditionally would bury the journal, while announcing nothing
337
+ * is what let the original leak run to a wedge with no warning — no [memory]
338
+ * line was emitted even once across the whole leak-to-wedge cycle.
339
+ *
340
+ * The quiet levels are the steady states that carry no news: healthy operation
341
+ * (below-high-water) and the structural "this host has no cgroup / the remedy
342
+ * is switched off" conditions, which would otherwise announce once per boot on
343
+ * every dev laptop for no reason.
344
+ *
345
+ * Pure; no I/O. */
346
+ const QUIET_MEMORY_LEVELS = Object.freeze([
347
+ 'below-high-water',
348
+ 'no-cgroup-reading',
349
+ 'no-cgroup-limit',
350
+ 'memory-remedy-disabled',
351
+ ]);
352
+
353
+ function shouldAnnounceMemoryLevel({ level, lastLevel } = {}) {
354
+ if (!level) return false;
355
+ if (level === lastLevel) return false;
356
+ return !QUIET_MEMORY_LEVELS.includes(level);
357
+ }
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
+
402
+ /** Keep only the restart timestamps still inside the rolling window.
403
+ *
404
+ * Split out from decideMemoryRestart so the "how many restarts have I already
405
+ * spent" bookkeeping is testable on its own, and so the caller can prune the
406
+ * persisted file with the same rule the decision uses. Non-parseable entries
407
+ * are dropped rather than throwing — a hand-edited or partially-written file
408
+ * degrades to "fewer restarts counted", which fails toward allowing a restart
409
+ * that reclaims memory rather than wedging.
410
+ *
411
+ * Pure; no I/O. */
412
+ function pruneRestartWindow({ timestamps, nowIso, windowMs } = {}) {
413
+ const now = Date.parse(nowIso);
414
+ const span = Number(windowMs);
415
+ if (!Number.isFinite(now)) return [];
416
+ const cap = (Number.isFinite(span) && span > 0) ? span : MEMORY_DEFAULTS.restartWindowMs;
417
+ return (Array.isArray(timestamps) ? timestamps : [])
418
+ .filter((t) => {
419
+ const ms = Date.parse(t);
420
+ return Number.isFinite(ms) && now - ms <= cap && ms <= now;
421
+ });
422
+ }
423
+
151
424
  module.exports = {
152
425
  MEMORY_DEFAULTS,
426
+ QUIET_MEMORY_LEVELS,
153
427
  decideMemoryDrain,
428
+ decideMemoryRestart,
429
+ formatMemoryText,
430
+ hasJobInFlight,
431
+ shouldAnnounceMemoryLevel,
432
+ resolveCgroupMemoryDir,
433
+ pruneRestartWindow,
154
434
  buildStateSnapshot,
155
435
  decodeStateSnapshot,
156
436
  restoreFromSnapshot,
@@ -1,7 +1,16 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Indexes the currently-reachable VMs by their live sessionId.
4
+ * Indexes the reachable VMs by every session id that identifies them: their
5
+ * live `sessionId` (from /api/session-info) AND their workflow-lock
6
+ * `holderSessionId` (from /api/step's editor-step.json). A claim maps to a VM
7
+ * through this index, so including the holder is what lets a claim attribute to
8
+ * the executing VM even after an editor restart drops the live sessionId (the
9
+ * VM reports sessionId:null but editor-step.json still names the holder).
10
+ *
11
+ * A live `sessionId` always wins a key collision over a `holderSessionId`: the
12
+ * live registration is the authoritative current identity, so it is indexed in
13
+ * a first pass and the holder pass only fills in ids not already claimed.
5
14
  * @param {Array|Object} vms The merged VM states on the dashboard.
6
15
  * @returns {Object} A map of sessionId -> VM number (n).
7
16
  */
@@ -14,6 +23,39 @@ function indexVmsBySessionId(vms) {
14
23
  map[vm.sessionId] = vm.n;
15
24
  }
16
25
  }
26
+ // Second pass: holderSessionId fills in only ids a live sessionId didn't
27
+ // already claim, so a live registration never loses to a stale holder.
28
+ for (const vm of list) {
29
+ if (vm && vm.holderSessionId && vm.n !== undefined && map[vm.holderSessionId] === undefined) {
30
+ map[vm.holderSessionId] = vm.n;
31
+ }
32
+ }
33
+ return map;
34
+ }
35
+
36
+ /**
37
+ * Indexes the reachable VMs by their fleet machine name, so a claim that only
38
+ * carries `claimedBy.machine` (and no resolvable sessionId) can still attribute
39
+ * to a VM. A VM's machine name is `<instancePrefix><n>` (the GCE instance name
40
+ * the editor stamps into CODEYAM_MACHINE), so the map is derived, not probed.
41
+ *
42
+ * This is the fallback for backend/HTTP-"Run" claims: the editor stamps
43
+ * `claimedBy.sessionId` only for a PTY-attached `plan-select`, so a
44
+ * server-driven autonomous claim has an empty sessionId and never resolves via
45
+ * {@link indexVmsBySessionId}. Its machine name always resolves.
46
+ * @param {Array|Object} vms The merged VM states on the dashboard.
47
+ * @param {string} instancePrefix The fleet instance-name prefix (INSTANCE_PREFIX).
48
+ * @returns {Object} A map of machine name -> VM number (n).
49
+ */
50
+ function indexVmsByMachine(vms, instancePrefix) {
51
+ const map = {};
52
+ if (!vms || !instancePrefix) return map;
53
+ const list = Array.isArray(vms) ? vms : Object.values(vms);
54
+ for (const vm of list) {
55
+ if (vm && vm.n !== undefined) {
56
+ map[`${instancePrefix}${vm.n}`] = vm.n;
57
+ }
58
+ }
17
59
  return map;
18
60
  }
19
61
 
@@ -36,9 +78,11 @@ function unsatisfiedDeps(dependsOn, completedSlugs) {
36
78
  * @param {Object[]} params.claims Array of PlanClaim objects from /api/plan-claims.
37
79
  * @param {Set|string[]} params.completedSlugs Completed plan slugs.
38
80
  * @param {Object} params.vmsBySessionId Map of sessionId -> VM number.
81
+ * @param {Object} [params.vmsByMachine] Map of machine name -> VM number, the
82
+ * fallback for backend claims whose sessionId is empty.
39
83
  * @returns {Object[]} Annotated plan rows.
40
84
  */
41
- function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
85
+ function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId, vmsByMachine }) {
42
86
  const list = Array.isArray(plans) ? plans : [];
43
87
  const claimsList = Array.isArray(claims) ? claims : [];
44
88
 
@@ -60,8 +104,14 @@ function resolvePlanRows({ plans, claims, completedSlugs, vmsBySessionId }) {
60
104
  if (claim) {
61
105
  const claimedBy = claim.claimedBy || claim.claimed_by || {};
62
106
  const sessionId = claimedBy.sessionId || claimedBy.session_id || null;
63
- const vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
64
-
107
+ // Prefer the authoritative live sessionId match; fall back to the claim's
108
+ // machine name (backend claims have an empty sessionId, so this is the
109
+ // only key that resolves them to a VM).
110
+ let vm = (sessionId && vmsBySessionId && vmsBySessionId[sessionId] !== undefined) ? vmsBySessionId[sessionId] : null;
111
+ if (vm === null && claimedBy.machine && vmsByMachine && vmsByMachine[claimedBy.machine] !== undefined) {
112
+ vm = vmsByMachine[claimedBy.machine];
113
+ }
114
+
65
115
  claimInfo = {
66
116
  vm,
67
117
  machine: claimedBy.machine || null,
@@ -218,8 +268,52 @@ function parsePlansSnapshotStdout(stdout) {
218
268
  };
219
269
  }
220
270
 
271
+ /**
272
+ * Unions the `/api/plan-claims` responses of several VMs in a group into one
273
+ * claim list, deduped by `(slug, uuid)`.
274
+ *
275
+ * Sourcing a group's claims from a single representative VM is fragile: that VM
276
+ * may lag, be unreachable, or — the shape this fixes — be the very VM holding
277
+ * the claim, whose own display filter is the one place the claim can be
278
+ * suppressed. Every *other* VM in the group carries the same ref as a plain
279
+ * `remote` claim, so unioning surfaces it regardless.
280
+ *
281
+ * When the same `(slug, uuid)` arrives from several VMs, the most-informed copy
282
+ * wins: a same-machine (`local`) view beats a `remote` one (only the holder
283
+ * knows its own session liveness), and among equals a live claim beats a
284
+ * not-live one. `fromVm` is stamped from the VM that supplied the winner so the
285
+ * panel's provenance stays meaningful.
286
+ * @param {Array<{vm: number, claims: Object[]}>} claimLists Per-VM claim responses.
287
+ * @returns {Object[]} The deduped union, each claim carrying its winning `fromVm`.
288
+ */
289
+ function unionClaims(claimLists) {
290
+ const lists = Array.isArray(claimLists) ? claimLists : [];
291
+ // Rank a claim by how authoritative its view is. Higher wins a tie on key.
292
+ const rank = claim => {
293
+ const local = claim && claim.source === 'local' ? 2 : 0;
294
+ const alive = claim && claim.sessionAlive === true ? 1 : 0;
295
+ return local + alive;
296
+ };
297
+ const byKey = new Map();
298
+ for (const entry of lists) {
299
+ if (!entry || !Array.isArray(entry.claims)) continue;
300
+ for (const claim of entry.claims) {
301
+ if (!claim || !claim.slug) continue;
302
+ const key = `${claim.slug}${claim.uuid || ''}`;
303
+ const candidate = { ...claim, fromVm: entry.vm };
304
+ const held = byKey.get(key);
305
+ if (!held || rank(candidate) > rank(held)) {
306
+ byKey.set(key, candidate);
307
+ }
308
+ }
309
+ }
310
+ return Array.from(byKey.values());
311
+ }
312
+
221
313
  module.exports = {
222
314
  indexVmsBySessionId,
315
+ indexVmsByMachine,
316
+ unionClaims,
223
317
  unsatisfiedDeps,
224
318
  resolvePlanRows,
225
319
  planSourceForProject,
package/npm/fleet-pool.js CHANGED
@@ -22,8 +22,11 @@
22
22
  //
23
23
  // On-disk shape (tolerant: a missing or malformed file is the empty pool):
24
24
  // { "vms": { "<n>": { status, branch, freshAt, claimedAt } } }
25
- // status: "pool" idle, claim-ready
26
- // "assigned" claimed by an operator, rebranded to `branch`
25
+ // status: "pool" idle, claim-ready (authenticated + push-ready)
26
+ // "pending-auth" reachable + LB-wired but awaiting operator auth;
27
+ // counts toward the provisioning target (so the
28
+ // keeper converges) but is NOT claimable
29
+ // "assigned" claimed by an operator, rebranded to `branch`
27
30
  // branch: the branch the VM is on (base branch while pooled)
28
31
  // freshAt: ISO timestamp of the last successful auth/health refresh —
29
32
  // drives evict-before-claim of a member whose token lapsed
@@ -45,6 +48,20 @@ const POOL_STATE_FILE = process.env.POOL_STATE_FILE
45
48
 
46
49
  const POOL = 'pool';
47
50
  const ASSIGNED = 'assigned';
51
+ // A VM that passed every readiness item EXCEPT provider auth (reachable,
52
+ // on-branch, broker up, editor+preview 200, git-push-ready). Provisioning has
53
+ // converged — the VM exists and is LB-wired — so it counts toward the refill
54
+ // target and the keeper stops churning; but it is never claimable, so the claim
55
+ // path still hands out only fully-authenticated VMs (the 2026-06-29 guarantee).
56
+ const PENDING_AUTH = 'pending-auth';
57
+
58
+ // Canonical status coercion: an unrecognized status is not a pool member.
59
+ function coerceStatus(status) {
60
+ if (status === ASSIGNED) return ASSIGNED;
61
+ if (status === PENDING_AUTH) return PENDING_AUTH;
62
+ if (status === POOL) return POOL;
63
+ return null;
64
+ }
48
65
 
49
66
  // ── Pure state helpers ──────────────────────────────────────────────────────
50
67
 
@@ -61,7 +78,7 @@ function normalizeState(raw) {
61
78
  const n = Number(key);
62
79
  if (!Number.isInteger(n) || n <= 0) continue;
63
80
  const e = vms[key] || {};
64
- const status = e.status === ASSIGNED ? ASSIGNED : e.status === POOL ? POOL : null;
81
+ const status = coerceStatus(e.status);
65
82
  if (!status) continue;
66
83
  out.vms[n] = {
67
84
  status,
@@ -91,6 +108,13 @@ function assignedMembers(state) {
91
108
  return membersByStatus(state, ASSIGNED);
92
109
  }
93
110
 
111
+ // VMs provisioned + LB-wired but awaiting operator auth. They count toward the
112
+ // refill target (provisioning has converged) yet are excluded from poolMembers,
113
+ // so selectForClaim never hands one out.
114
+ function pendingAuthMembers(state) {
115
+ return membersByStatus(state, PENDING_AUTH);
116
+ }
117
+
94
118
  // True when a pool entry's auth/health is older than `ttlMs` (its token may
95
119
  // have lapsed). An entry with no `freshAt` is treated as stale — "freshness
96
120
  // not proven" fails closed, mirroring fleet-ready's auth gate. Only meaningful
@@ -109,12 +133,16 @@ function staleMembers(state, nowMs, ttlMs) {
109
133
  return poolMembers(state).filter((n) => isStale(state.vms[n], nowMs, ttlMs));
110
134
  }
111
135
 
112
- // How many NEW pool VMs the keeper must provision to reach `target` idle
113
- // members. Assigned VMs do not count toward the pool — once claimed they're
114
- // gone from the warm set until released — so a claim naturally triggers a
115
- // refill. Never negative (an over-target pool is shrunk by policy, not here).
136
+ // How many NEW VMs the keeper must provision to reach `target`. `have` counts
137
+ // BOTH claim-ready pool members AND pending-auth members: provisioning has
138
+ // converged once N reachable+wired VMs exist, regardless of auth, so the deficit
139
+ // closes and the keeper stops churning (the 2026-07-09 endless-refill fix). Only
140
+ // the provisioning denominator widens — poolMembers (claim eligibility) is
141
+ // untouched, so a claim still selects only fully-authenticated VMs. Assigned VMs
142
+ // do not count (claimed → gone from the warm set until released). Never negative
143
+ // (an over-target pool is shrunk by policy, not here).
116
144
  function refillCount(state, target) {
117
- const have = poolMembers(state).length;
145
+ const have = poolMembers(state).length + pendingAuthMembers(state).length;
118
146
  return Math.max(0, target - have);
119
147
  }
120
148
 
@@ -141,16 +169,18 @@ function selectForClaim(state, count, branch, nowIso) {
141
169
  }
142
170
 
143
171
  // Register (or overwrite) a VM in the pool state. The keeper calls this after
144
- // provisioning a fresh READY VM (status=pool) and the claim path can re-add an
145
- // assigned VM. `freshAt` defaults to now for a pool member so a just-verified
146
- // VM isn't immediately stale.
172
+ // provisioning: status=pool for a fully-READY VM, status=pending-auth for one
173
+ // that is reachable+wired but still awaiting operator auth; the claim path can
174
+ // re-add an assigned VM. `freshAt` is stamped to now for a pool OR pending-auth
175
+ // member (both were just verified live) so neither is immediately stale; an
176
+ // unknown status coerces to pool.
147
177
  function applyAdd(state, n, status, branch, nowIso) {
148
178
  const next = normalizeState(state);
149
- const st = status === ASSIGNED ? ASSIGNED : POOL;
179
+ const st = coerceStatus(status) || POOL;
150
180
  next.vms[n] = {
151
181
  status: st,
152
182
  branch: branch || null,
153
- freshAt: st === POOL ? nowIso : (next.vms[n] && next.vms[n].freshAt) || null,
183
+ freshAt: st === ASSIGNED ? (next.vms[n] && next.vms[n].freshAt) || null : nowIso,
154
184
  claimedAt: st === ASSIGNED ? nowIso : null,
155
185
  };
156
186
  return next;
@@ -188,6 +218,18 @@ function applyMarkFresh(state, n, nowIso) {
188
218
  return next;
189
219
  }
190
220
 
221
+ // Promote a pending-auth VM to a claim-ready pool member — the dashboard calls
222
+ // this the instant an operator completes that VM's OAuth, so it becomes
223
+ // claimable with NO re-provision. `freshAt` resets to now (it was just proven
224
+ // authenticated). A no-op unless the VM is currently pending-auth, so it is safe
225
+ // to call for any authenticated VM (an already-pooled or absent VM is untouched).
226
+ function applyPromote(state, n, nowIso) {
227
+ const next = normalizeState(state);
228
+ if (!next.vms[n] || next.vms[n].status !== PENDING_AUTH) return next;
229
+ next.vms[n] = { ...next.vms[n], status: POOL, freshAt: nowIso, claimedAt: null };
230
+ return next;
231
+ }
232
+
191
233
  // ── Atomic file layer ───────────────────────────────────────────────────────
192
234
 
193
235
  // Synchronous millisecond sleep without a busy CPU spin — used by withLock's
@@ -260,9 +302,11 @@ module.exports = {
260
302
  POOL_STATE_FILE,
261
303
  POOL,
262
304
  ASSIGNED,
305
+ PENDING_AUTH,
263
306
  normalizeState,
264
307
  poolMembers,
265
308
  assignedMembers,
309
+ pendingAuthMembers,
266
310
  isStale,
267
311
  staleMembers,
268
312
  refillCount,
@@ -271,6 +315,7 @@ module.exports = {
271
315
  applyRelease,
272
316
  applyEvict,
273
317
  applyMarkFresh,
318
+ applyPromote,
274
319
  withLock,
275
320
  readState,
276
321
  writeState,
@@ -312,6 +357,9 @@ function main(argv) {
312
357
  case 'assigned-members':
313
358
  process.stdout.write(`${assignedMembers(readState()).join(' ')}\n`);
314
359
  return 0;
360
+ case 'pending-auth-members':
361
+ process.stdout.write(`${pendingAuthMembers(readState()).join(' ')}\n`);
362
+ return 0;
315
363
  case 'refill-count': {
316
364
  const target = Number(flags.target);
317
365
  if (!Number.isInteger(target) || target < 0) {
@@ -357,8 +405,10 @@ function main(argv) {
357
405
  process.stderr.write('fleet-pool: add needs --vm <pos int>\n');
358
406
  return 2;
359
407
  }
360
- const status = flags.status === ASSIGNED ? ASSIGNED : POOL;
361
- withLock(() => writeState(applyAdd(readState(), vm, status, flags.branch, now)));
408
+ // applyAdd coerces the status (pool | pending-auth | assigned; unknown →
409
+ // pool), so pass the raw flag through — this is how the keeper adds a
410
+ // reachable-but-unauthed VM with `--status pending-auth`.
411
+ withLock(() => writeState(applyAdd(readState(), vm, flags.status, flags.branch, now)));
362
412
  return 0;
363
413
  }
364
414
  case 'release': {
@@ -388,9 +438,18 @@ function main(argv) {
388
438
  withLock(() => writeState(applyMarkFresh(readState(), vm, now)));
389
439
  return 0;
390
440
  }
441
+ case 'promote': {
442
+ const vm = Number(flags.vm);
443
+ if (!Number.isInteger(vm) || vm <= 0) {
444
+ process.stderr.write('fleet-pool: promote needs --vm <pos int>\n');
445
+ return 2;
446
+ }
447
+ withLock(() => writeState(applyPromote(readState(), vm, now)));
448
+ return 0;
449
+ }
391
450
  default:
392
451
  process.stderr.write(
393
- 'fleet-pool: usage: <list|pool-members|assigned-members|refill-count|stale|claim|add|release|evict|mark-fresh> [--flags]\n',
452
+ 'fleet-pool: usage: <list|pool-members|assigned-members|pending-auth-members|refill-count|stale|claim|add|release|evict|mark-fresh|promote> [--flags]\n',
394
453
  );
395
454
  return 2;
396
455
  }