@codeyam-editor/codeyam-editor 0.1.3 → 0.1.5

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 (66) hide show
  1. package/npm/cloud-branch-switch.js +1 -1
  2. package/npm/cloud.js +1693 -96
  3. package/npm/container.js +14 -0
  4. package/npm/disk-pressure.js +167 -0
  5. package/npm/editor-dev.js +107 -15
  6. package/npm/editor.js +97 -37
  7. package/npm/fleet-auth-health.js +75 -0
  8. package/npm/fleet-auth.js +6 -1
  9. package/npm/fleet-build-data.js +130 -0
  10. package/npm/fleet-cleanup.js +145 -7
  11. package/npm/fleet-destroy-safety.js +55 -0
  12. package/npm/fleet-effect-status.js +33 -32
  13. package/npm/fleet-host.js +74 -0
  14. package/npm/fleet-job-badge.js +46 -2
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +231 -0
  18. package/npm/fleet-prewarm.js +165 -0
  19. package/npm/fleet-public-url.js +160 -0
  20. package/npm/fleet-queue.js +38 -1
  21. package/npm/fleet-reconcile.js +100 -1
  22. package/npm/fleet-reset.js +298 -13
  23. package/npm/fleet-roster.js +6 -8
  24. package/npm/fleet-self-update.js +142 -0
  25. package/npm/fleet-setup-steps.js +105 -0
  26. package/npm/fleet-sleep-status.js +33 -0
  27. package/npm/fleet-strand.js +150 -0
  28. package/npm/fleet-stuck-status.js +111 -0
  29. package/npm/fleet-vm-access.js +234 -0
  30. package/npm/fleet-vm-readiness.js +104 -0
  31. package/npm/fleet-vm-slugs.js +116 -0
  32. package/npm/google-ingress.js +211 -0
  33. package/npm/ingress-invoke.js +292 -0
  34. package/npm/launch-config.js +310 -4
  35. package/npm/scenario-check.js +684 -33
  36. package/npm/scenario-handlers.js +94 -12
  37. package/npm/scenario-metrics.js +116 -1
  38. package/npm/scenario-mocks.js +20 -0
  39. package/npm/scenario-playwright.js +366 -29
  40. package/npm/script-drift.js +81 -0
  41. package/npm/utils.js +453 -43
  42. package/npm/vm-urls.js +69 -8
  43. package/package.json +5 -5
  44. package/ui/dist/assets/ContainerInspector-D7VZkkaj.js +2 -0
  45. package/ui/dist/assets/EditorShell-DHhIaotd.css +1 -0
  46. package/ui/dist/assets/EditorShell-r-796N_W.js +191 -0
  47. package/ui/dist/assets/{Markdown-BHBqz-Mj.js → Markdown-BoLHHFXH.js} +1 -1
  48. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +1 -0
  49. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +5 -0
  50. package/ui/dist/assets/ScenarioDataPanelFullPage-CBLTGC4-.js +1 -0
  51. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +21 -0
  52. package/ui/dist/assets/StepsSimulator-BFldWyoX.js +1 -0
  53. package/ui/dist/assets/index-CIXZkwfB.css +1 -0
  54. package/ui/dist/assets/{index-KDFAgt2e.js → index-QsOiOG9x.js} +9 -9
  55. package/ui/dist/assets/useEvents-DHkLJZR4.js +1 -0
  56. package/ui/dist/codeyam-mark.svg +4 -0
  57. package/ui/dist/codeyam-wordmark.svg +5 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +0 -2
  60. package/ui/dist/assets/EditorShell-CGsBSA6u.js +0 -186
  61. package/ui/dist/assets/ScenarioDataPanel-nuLXJzLf.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-Bnghtj8a.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-B_Za9jL8.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-Bih6HJ_u.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -0,0 +1,130 @@
1
+ 'use strict';
2
+
3
+ // Assemble the per-VM wire object the dashboard's /data response carries to the
4
+ // browser. This is the single source of truth for a card's transport shape: the
5
+ // card UI (scripts/operator/fleet-dashboard/public/index.html) reads `v.<field>`
6
+ // off each entry, so every field the card renders MUST be emitted here.
7
+ //
8
+ // It was split out of server.js's buildData() so the shape is unit-testable.
9
+ // The regression that motivated the split: `currentProvider` (the VM's
10
+ // configured build agent) was merged onto the poll state AND read by the card to
11
+ // render the 🤖 agent badge, but it was silently missing from this envelope — so
12
+ // it lived in server memory and never reached the browser, and the badge never
13
+ // appeared. A hand-maintained object literal inside a 197KB monolith has no
14
+ // guard against that drift; a pure function does.
15
+ //
16
+ // `v` is the merged poll state (state.vms[n]). `ctx` carries the derived,
17
+ // cross-VM, and side-effecting values buildData computes per VM (status,
18
+ // debounced broker tri-state, reconciled job, queue position, launch cfg, …) —
19
+ // the things this pure function can't know on its own.
20
+ function buildVmEnvelope(v, ctx) {
21
+ return {
22
+ n: ctx.n,
23
+ url: v.url || null,
24
+ reachable: !!v.reachable,
25
+ hasSession: !!v.hasSession,
26
+ // Three-state: true = broker confirmed up, false = confirmed down, null =
27
+ // editor unreachable so broker state is unknown. Never conflate "down" with
28
+ // "unreachable" — that mismatch is what made the card show "broker down"
29
+ // while a direct pgrep proved the broker was up.
30
+ brokerLive: v.reachable ? !!v.agentBrokerLive : null,
31
+ // Broker tri-state: 'down' | 'idle' | 'agent-live' | 'unknown' (derived
32
+ // upstream, debounced). Richer than brokerLive's is-an-agent-live boolean.
33
+ brokerStatus: ctx.brokerStatus,
34
+ awaitingInput: !!v.awaitingUserInput,
35
+ // True when the editor reports the build workflow reached its terminal step
36
+ // and is parked awaiting cleanup (session_info_response.rs emits it at
37
+ // step >= total). Flows onto poll state via the `...info` spread in server.js
38
+ // exactly like `feature`/`awaitingUserInput`; defaulted false here so a legacy
39
+ // editor that predates the field degrades to today's behavior. Consumed by
40
+ // fleet-live-status.js to read a parked-but-done agent as calm 'session-done'
41
+ // instead of active-green 'building'.
42
+ needsCleanup: !!v.needsCleanup,
43
+ feature: v.feature || null,
44
+ step: v.step != null ? v.step : null,
45
+ mode: v.mode || null,
46
+ label: v.label || null,
47
+ status: ctx.status,
48
+ orphan: ctx.orphan || null,
49
+ degradedReasons: ctx.degradedReasons || [],
50
+ // Derived presentation split of degradedReasons (vm: Dashboard 'Finishing
51
+ // setup' state): `setupSteps` are expected first-run wiring rendered as a
52
+ // calm blue checklist, `attentionReasons` are genuine problems kept in the
53
+ // amber ⚠ treatment. `degradedReasons` is left intact for the many places
54
+ // that still consume the flat list (filters, sort, reconcile); these two are
55
+ // purely additive. Both default to [] so a legacy/healthy VM renders neither
56
+ // block. See npm/fleet-setup-steps.js::classifyReasons.
57
+ setupSteps: Array.isArray(ctx.setupSteps) ? ctx.setupSteps : [],
58
+ attentionReasons: Array.isArray(ctx.attentionReasons) ? ctx.attentionReasons : [],
59
+ isHead: !!ctx.isHead,
60
+ // True when this VM holds the queue head but is unreachable (operator
61
+ // decides manually whether to release it).
62
+ headHolderUnreachable: !!ctx.headHolderUnreachable,
63
+ queuePos: ctx.queuePos != null ? ctx.queuePos : null,
64
+ queueLen: ctx.queueLen,
65
+ job: ctx.job || null,
66
+ config: ctx.config || null,
67
+ project: ctx.project != null ? ctx.project : null,
68
+ variant: ctx.variant != null ? ctx.variant : null,
69
+ cardIdentity: ctx.cardIdentity,
70
+ brokerHistory: Array.isArray(v.brokerHistory) ? v.brokerHistory : [],
71
+ brokerDeath: ctx.brokerDeath,
72
+ // Auth state + reason + token expiry. Only surfaced when the editor is
73
+ // reachable (an unreachable VM's cached auth is stale and misleading).
74
+ providerAuth: v.reachable && v.providerAuth ? v.providerAuth : null,
75
+ effects: v.effects || null,
76
+ // The VM's live-configured build agent (lowercase provider), reported by
77
+ // /api/session-info and merged (sticky) onto the poll state. Drives the
78
+ // card's 🤖 agent badge. null for an older VM whose editor predates the
79
+ // field (the card omits the badge rather than rendering a blank chip).
80
+ currentProvider: v.currentProvider || null,
81
+ // Raw workflow timestamps (ISO) surfaced by /api/session-info and merged onto
82
+ // the poll state via the `...info` spread, exactly like feature/step/label.
83
+ // The card live-ticks the elapsed minutes off these between polls. null for a
84
+ // legacy editor that predates the fields, or a VM with no active step.
85
+ startedAt: v.startedAt || null,
86
+ featureStartedAt: v.featureStartedAt || null,
87
+ // The server-computed stuck / needs-you verdict (npm/fleet-stuck-status.js):
88
+ // { stepElapsedMs, level: 'ok'|'slow'|'stuck', needsYou, needsYouElapsedMs }.
89
+ // The authoritative level only changes on poll; the card colors the step
90
+ // tenure badge by it. null when buildData couldn't compute one.
91
+ attention: ctx.attention || null,
92
+ // Per-VM card readiness derived upstream (npm/fleet-vm-readiness.js): one of
93
+ // READINESS_STATES. The card colors the border and GATES the editor link on
94
+ // it — a non-'ready' VM never presents its public URL as live, because the
95
+ // LB would 503. Computed in buildData where the broker tri-state / job / LB
96
+ // mode are on hand, then carried here so the browser needs no extra request.
97
+ readiness: ctx.readiness || null,
98
+ // The raw LB backend-health map { editor, preview } the readiness derivation
99
+ // consumed, surfaced so the card can show a precise tooltip (which surface
100
+ // is unwired). null in legacy mode or before the first slow probe lands.
101
+ backendHealth: v.backendHealth || null,
102
+ // The per-VM IAP access whitelist (the principals explicitly granted
103
+ // resource-level access to THIS VM, from the fleet-vm-access sidecar). Merged
104
+ // onto the poll state at server.js, it drives the card's 🔐 access line and
105
+ // the Manage access modal's "this VM only" list. It was previously dropped
106
+ // here — the exact currentProvider-style envelope drift this module guards
107
+ // against — so the line and list rendered empty even when the sidecar held
108
+ // principals. Defaults to [] for a VM with no list.
109
+ vmAccess: Array.isArray(v.vmAccess) ? v.vmAccess : [],
110
+ };
111
+ }
112
+
113
+ // The fields the card UI depends on being present in every /data VM entry. This
114
+ // is the contract `buildVmEnvelope` must satisfy: the regression that prompted
115
+ // this module was a field the card read but the envelope didn't emit, so the
116
+ // test asserts the envelope keys are a superset of this list. Adding a field the
117
+ // card renders means adding it here AND in buildVmEnvelope — the test fails loud
118
+ // if you do one without the other.
119
+ const REQUIRED_CARD_FIELDS = [
120
+ 'n', 'url', 'reachable', 'hasSession', 'brokerLive', 'brokerStatus',
121
+ 'awaitingInput', 'needsCleanup', 'feature', 'step', 'mode', 'label', 'status', 'orphan',
122
+ 'degradedReasons', 'setupSteps', 'attentionReasons',
123
+ 'isHead', 'headHolderUnreachable', 'queuePos', 'queueLen',
124
+ 'job', 'config', 'project', 'variant', 'cardIdentity', 'brokerHistory',
125
+ 'brokerDeath', 'providerAuth', 'effects', 'currentProvider',
126
+ 'readiness', 'backendHealth', 'vmAccess',
127
+ 'startedAt', 'featureStartedAt', 'attention',
128
+ ];
129
+
130
+ module.exports = { buildVmEnvelope, REQUIRED_CARD_FIELDS };
@@ -175,13 +175,28 @@ function clearExitMarker(dir, n) {
175
175
  catch (_) { /* missing is fine */ }
176
176
  }
177
177
 
178
+ // True when an integer exit code is the shell's "killed by signal N" encoding,
179
+ // i.e. 128 + N for N in 1..37 — the realistic kills being 143 (SIGTERM, the
180
+ // `launchctl bootout` graceful teardown), 137 (SIGKILL), 130 (SIGINT) and 129
181
+ // (SIGHUP). Pure boundary check: 128 itself is NOT a signal kill (it's `exit
182
+ // 128`, an ordinary failure code), so the range is the OPEN interval
183
+ // (128, 165]. Anything outside it — a small `exit 1` from the addVm script's own
184
+ // failure branches, 0, NaN, null — is not a signal kill. The split that matters:
185
+ // "did a signal kill the provision shell" (interrupted → reconcile) vs "did the
186
+ // provision logic itself decide it failed" (real error). Exported so the matrix
187
+ // is unit-testable directly.
188
+ function isSignalKillExit(code) {
189
+ return Number.isInteger(code) && code > 128 && code <= 165;
190
+ }
191
+
178
192
  // Decide how a snapshot-restored `add` should be re-attached after a dashboard
179
193
  // restart, BEFORE any gcloud probe. Pure: takes the durable signals the boot
180
194
  // path gathers — whether the exit marker exists, its recorded code, and whether
181
195
  // the detached provision child is still alive (recorded pid, `kill -0`).
182
196
  // verdict when dispatch
183
197
  // 'done' marker present, exit code 0 roster + recordLaunch, clear sidecar/marker
184
- // 'error' marker present, non-zero exit code surface the real failure, clear sidecar/marker
198
+ // 'reconcile' marker present, SIGNAL-kill code (128<c≤165) interrupted, not failed — gcloud probe decides
199
+ // 'error' marker present, other non-zero exit code surface the real failure, clear sidecar/marker
185
200
  // 'running' no marker yet, child still alive RE-ATTACH: keep polling, do NOT mark error
186
201
  // 'reconcile' child gone, no usable marker fall through to fc.reapVerdict (gcloud probe)
187
202
  // This is the durable fix's brain: a provision that survived the kill (still
@@ -192,20 +207,65 @@ function clearExitMarker(dir, n) {
192
207
  // `reconcile` hands that exact (exists, containerUp, projectWired) decision to
193
208
  // fc.reapVerdict. A marker whose first line isn't an integer is treated as
194
209
  // absent (parseExitMarker → null) so a corrupt marker reconciles rather than
195
- // asserting a bogus code. The matrix is exhaustively tested without a live VM.
210
+ // asserting a bogus code.
211
+ //
212
+ // SIGNAL-kill codes (improve46): `setsid` shields the detached provision from a
213
+ // dashboard-process-GROUP SIGKILL (the 2GB launchd hard cap), but NOT from a
214
+ // `launchctl bootout`, which tears down the whole launchd job tree regardless of
215
+ // session/process-group. A graceful SIGTERM then reaches the "detached" child and
216
+ // its EXIT trap records 143 (128+SIGTERM) into the marker. That is an INTERRUPTED
217
+ // add, not a failed one — the manual re-add succeeds in seconds off the image the
218
+ // interrupted build already left on the VM. So a signal-kill code routes to
219
+ // 'reconcile' (let the gcloud probe read ground truth and roster / recover) rather
220
+ // than 'error' (a false add-failed that throws away a resumable build). A genuine
221
+ // provision failure exits with a small non-zero code (e.g. `exit 1`), which STILL
222
+ // classifies 'error'. The matrix is exhaustively tested without a live VM.
196
223
  function reattachVerdict({ markerPresent, exitCode, childAlive } = {}) {
197
224
  if (markerPresent) {
198
225
  // Guard the null/undefined coercion BEFORE Number() — `Number(null)` is 0,
199
226
  // which would masquerade as a clean exit. A marker with no parseable code
200
227
  // (half-written / corrupt) must reconcile, not assert success.
201
228
  const code = exitCode == null ? NaN : Number(exitCode);
202
- if (Number.isInteger(code)) return code === 0 ? 'done' : 'error';
229
+ if (Number.isInteger(code)) {
230
+ if (code === 0) return 'done';
231
+ // A signal killed the provision shell (e.g. bootout SIGTERM → 143): the add
232
+ // was interrupted, not failed — reconcile against real VM state instead of
233
+ // surfacing a false add-failed and discarding a resumable build.
234
+ if (isSignalKillExit(code)) return 'reconcile';
235
+ return 'error';
236
+ }
203
237
  return 'reconcile'; // marker present but no parseable code — let gcloud decide
204
238
  }
205
239
  if (childAlive) return 'running';
206
240
  return 'reconcile';
207
241
  }
208
242
 
243
+ // improve45 — map a reattachVerdict tag to the dashboard's NEXT action, now that
244
+ // a clean exit marker is NECESSARY BUT NOT SUFFICIENT to roster. A detached
245
+ // `cloud:up` can exit 0 AFTER the image build but BEFORE `docker compose up`
246
+ // ever runs — an ssh broken-pipe or dashboard SIGKILL landing in that window,
247
+ // with the wrapping EXIT trap still recording 0 (the live VM2 signature: built
248
+ // image, repo cloned, ZERO editor containers). So 'done' no longer means "roster
249
+ // it"; it means "confirm the container is actually up first." Both the clean-exit
250
+ // ('done') and the no-usable-marker ('reconcile') cases therefore route through
251
+ // the SAME gcloud container + wiring probe (reconcileViaGcloud → fc.reapVerdict),
252
+ // which rosters only a genuinely-up + wired add and routes a half-provisioned one
253
+ // to recover-bootstrap. 'error' (real non-zero exit) and 'running' (child still
254
+ // alive) are unchanged. reattachVerdict stays the honest classifier of the
255
+ // child's exit SIGNAL; this helper expresses how that signal is CONSUMED now that
256
+ // exit-0 no longer implies rostering. Pure: server.js maps the returned action to
257
+ // its side-effecting step, so the matrix is exhaustively testable without a VM.
258
+ // verdict next step meaning
259
+ // 'done' 'probe' confirm the container before rostering
260
+ // 'reconcile' 'probe' no usable marker — let the gcloud probe decide
261
+ // 'error' 'surface-error' real failure — show add-failed with the code
262
+ // 'running' 'watch' child still alive — keep polling for the marker
263
+ function reattachNextStep(verdict) {
264
+ if (verdict === 'error') return 'surface-error';
265
+ if (verdict === 'running') return 'watch';
266
+ return 'probe'; // 'done' and 'reconcile' both confirm via the gcloud probe
267
+ }
268
+
209
269
  // Anti-loop budget for the `recover-bootstrap` path. Pure: given the count of
210
270
  // recoveries ALREADY persisted on the sidecar, return the next attempt number,
211
271
  // whether the reap should proceed (re-run cloud:up) or treat the VM as
@@ -271,11 +331,87 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
271
331
  // posture). "Busy" (a live session or an in-flight add to this number) also
272
332
  // holds the entry for an operator rather than auto-removing — the shouldn't-
273
333
  // happen-but-fail-safe case where a vanished instance still reports a session.
274
- function reconcileRosteredVm({ exists, hasSession, hasPendingAdd } = {}) {
334
+ // verdict when dispatch
335
+ // 'cleanup' instance gone AND the VM is idle runDestroyCleanup
336
+ // 'leave' exists uncertain (null), OR the VM is busy do nothing this pass
337
+ // 'keep' instance still exists do nothing (healthy)
338
+ // 'reset' existing idle instance unreachable too long trigger auto-reset
339
+ // Fail-safe by construction: cleanup fires ONLY on a DEFINITIVE absence
340
+ // (exists === false) — a flaky/transient describe (null) is always 'leave', so a
341
+ // gcloud blip can never evict a healthy VM (mirroring reapVerdict's `leave`
342
+ // posture). "Busy" (a live session or an in-flight add to this number) also
343
+ // holds the entry for an operator rather than auto-removing — the shouldn't-
344
+ // happen-but-fail-safe case where a vanished instance still reports a session.
345
+ // auto-reset escalation (vm: OOM Wedge Prevention):
346
+ // When the VM has been unreachable for at least RESET_UNREACHABLE_THRESHOLD
347
+ // (e.g. 200, which is roughly 10 minutes at a 3s poll rate), we issue a 'reset'
348
+ // verdict. This auto-reset is guarded and bounded by a cooldown window (in ms)
349
+ // and a max attempts limit to prevent infinite reboot loops.
350
+ //
351
+ // false-unreachable reboot safety (2026-06-25): "unreachable" as the dashboard
352
+ // sees it means the LOCALLY-FORWARDED port is dark — which is ALSO the signature
353
+ // of an operator-side fault (the laptop's gcloud auth expired, the ssh -L tunnel
354
+ // zombied, a laptop network blip) where the VM itself is perfectly healthy.
355
+ // Rebooting a healthy production VM off that signal interrupts a live session.
356
+ // So three operator-fault guards are layered BEFORE the cooldown/attempts budget
357
+ // (a reset is a last resort, never the response to a dark local port). Each is
358
+ // OPT-IN: it only changes the verdict when the caller supplies the fact, so
359
+ // legacy callers (and the OOM-wedge tests) that pass none keep the old behavior.
360
+ // operatorAuthDegraded our own gcloud auth is broken → EVERY VM reads
361
+ // unreachable; rebooting them all is exactly wrong → keep
362
+ // fleetWideUnreachable a simultaneous N-VM "outage" is the operator (auth/
363
+ // tunnel/network), not N dead VMs → keep
364
+ // gcpReachable GCP-side confirmation: true ⇒ the VM answers from
365
+ // GCP's vantage, so the dark local port is a laptop
366
+ // tunnel fault → 'respawn' (never reset); null ⇒ the
367
+ // GCP-side probe was inconclusive → keep (don't escalate
368
+ // a destructive reset on absence of evidence); false ⇒
369
+ // genuinely unreachable from GCP too → fall through to
370
+ // the cooldown/attempts budget and reset as before.
371
+ function reconcileRosteredVm({ exists, hasSession, hasPendingAdd, streak, resetHistory, threshold, cooldownMs, maxAttempts, operatorAuthDegraded, fleetWideUnreachable, gcpReachable } = {}) {
275
372
  if (exists === null || exists === undefined) return "leave";
276
- if (exists) return "keep";
277
- if (hasSession || hasPendingAdd) return "leave";
278
- return "cleanup";
373
+ if (!exists) {
374
+ if (hasSession || hasPendingAdd) return "leave";
375
+ return "cleanup";
376
+ }
377
+
378
+ // If the VM exists, check if it's dead-unreachable and eligible for auto-reset.
379
+ if (streak !== undefined && threshold !== undefined) {
380
+ if (streak >= threshold) {
381
+ if (hasSession || hasPendingAdd) {
382
+ // Do NOT auto-reset a VM that is busy (holding a live session or an in-flight add)
383
+ return "keep";
384
+ }
385
+
386
+ // Operator-fault guards — layered before the cooldown/attempts budget so a
387
+ // laptop-side fault never even consumes a reset attempt.
388
+ if (operatorAuthDegraded) return "keep";
389
+ if (fleetWideUnreachable) return "keep";
390
+ if (gcpReachable === true) return "respawn";
391
+ if (gcpReachable === null) return "keep";
392
+
393
+ const history = resetHistory || { count: 0, lastResetAt: 0 };
394
+ const attempts = history.count || 0;
395
+ const limit = maxAttempts !== undefined ? maxAttempts : 3;
396
+
397
+ if (attempts >= limit) {
398
+ // Budget exhausted (VM wedges repeatedly) -> park (needs-manual-recovery status)
399
+ return "keep";
400
+ }
401
+
402
+ const lastReset = history.lastResetAt || 0;
403
+ const cooldown = cooldownMs !== undefined ? cooldownMs : 600000; // default 10 minutes
404
+ const now = Date.now();
405
+ if (now - lastReset < cooldown) {
406
+ // Within cooldown window -> do not reset again yet
407
+ return "keep";
408
+ }
409
+
410
+ return "reset";
411
+ }
412
+ }
413
+
414
+ return "keep";
279
415
  }
280
416
 
281
417
  module.exports = {
@@ -293,5 +429,7 @@ module.exports = {
293
429
  parseExitMarker,
294
430
  readExitMarker,
295
431
  clearExitMarker,
432
+ isSignalKillExit,
296
433
  reattachVerdict,
434
+ reattachNextStep,
297
435
  };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // Pure decision + formatting helpers for the fleet dashboard's destroy/deroster
3
+ // false-unreachable safety (2026-06-26 incident, where a reauth-triggered
4
+ // false-unreachable cascade permanently deleted VM2/3/4). The dashboard's
5
+ // server.js gathers the live operator-fault facts (state.operatorAuthDegraded +
6
+ // fleetWideUnreachableNow) and runs the side effects (spawn the delete, log,
7
+ // stop+deroster); the DECISIONS those facts drive — "is this destroy blocked,
8
+ // and why" and "what does the attribution log line read" — live here so they
9
+ // are unit-testable in isolation, the same pure/IO split npm/fleet-auth-health.js
10
+ // (classifyOperatorAuth) and npm/fleet-cleanup.js (reconcileRosteredVm) follow.
11
+ //
12
+ // Pure logic only — no I/O, no env reads, no clock, no global state.
13
+
14
+ // Classify whether a destructive removal (hard destroy or --shrink) must be
15
+ // REFUSED because the operator's own side is faulted, from the two booleans
16
+ // server.js computes. Returns { blocked, reason }: blocked is true when the
17
+ // operator's gcloud auth is degraded OR a fleet-wide unreachable spike is in
18
+ // progress — the signature of a laptop-side fault (which makes EVERY VM read
19
+ // unreachable), NOT N genuinely-dead VMs. authDegraded takes precedence in the
20
+ // reason because it is the upstream cause (a broken operator auth is what
21
+ // produces the fleet-wide false-unreachable spike in the first place), so the
22
+ // message points at the thing to fix.
23
+ // authDegraded state.operatorAuthDegraded — operator gcloud auth is broken
24
+ // fleetWide fleetWideUnreachableNow() — N+ VMs unreachable at once
25
+ function classifyDestroyBlock({ authDegraded, fleetWide } = {}) {
26
+ if (authDegraded) {
27
+ return { blocked: true, reason: 'operator gcloud auth degraded' };
28
+ }
29
+ if (fleetWide) {
30
+ return { blocked: true, reason: 'fleet-wide unreachable spike in progress (operator-side fault signature)' };
31
+ }
32
+ return { blocked: false, reason: null };
33
+ }
34
+
35
+ // Build the single attributable destroy-decision log line. Mirrors the
36
+ // performAutoReset "confirmed … not fleet-wide" template so every destroy —
37
+ // proceed OR refused, across every trigger source — is diagnosable from one
38
+ // grep. `remote` ({ ip, ua }) is the caller attribution whose absence made the
39
+ // 2026-06-26 delete impossible to trace back to its trigger; null/absent renders
40
+ // as `n/a` (e.g. the shrink / reap paths have no HTTP caller). All the flag
41
+ // fields are normalized to real booleans so a missing field never reads as
42
+ // `undefined` in the line.
43
+ function formatDestroyDecision(info = {}) {
44
+ const remote = info.remote ? `${info.remote.ip || '?'}/${info.remote.ua || '?'}` : 'n/a';
45
+ return (
46
+ `destroy-decision: VM-${info.n} source=${info.source} force=${!!info.force} ` +
47
+ `override=${!!info.overrideSafety} remote=${remote} hasSession=${!!info.hasSession} ` +
48
+ `authDegraded=${!!info.authDegraded} fleetWide=${!!info.fleetWide} → ${info.outcome}`
49
+ );
50
+ }
51
+
52
+ module.exports = {
53
+ classifyDestroyBlock,
54
+ formatDestroyDecision,
55
+ };
@@ -58,12 +58,10 @@ function resetReason({ agent, session, dirty, behind, codeyam }) {
58
58
  // meaningless for them).
59
59
  const ACTION_IDS = [
60
60
  'update-client-source',
61
- 'update-editor-source',
62
- 'reset-editor-source',
63
61
  'reset-client',
62
+ 'end-session',
64
63
  'rebuild-binary',
65
64
  'update-reset-everything',
66
- 'run-setup',
67
65
  ];
68
66
 
69
67
  // facts (a plain object gathered by server.js — no I/O):
@@ -105,13 +103,6 @@ function computeButtonEffects(facts) {
105
103
  ? { effect: 'changes', reason: behindReason(client.behind, f.clientBranch) }
106
104
  : { effect: 'noop', reason: 'up to date with ' + originRef(f.clientBranch) };
107
105
 
108
- // (2) Update editor — advance the editor source the binary builds from.
109
- const updateEditor = editor == null
110
- ? { effect: 'unknown', reason: 'editor state not checked' }
111
- : editor.behind > 0
112
- ? { effect: 'changes', reason: behindReason(editor.behind, f.editorBranch) }
113
- : { effect: 'noop', reason: 'up to date with ' + originRef(f.editorBranch) };
114
-
115
106
  // (5) Rebuild binary — advances source then compiles. Changes iff the running
116
107
  // binary's build commit differs from the editor origin tip. Unknown when we
117
108
  // don't have both commits to compare.
@@ -121,25 +112,38 @@ function computeButtonEffects(facts) {
121
112
  ? { effect: 'changes', reason: `binary at ${short(editor.buildCommit)}, ${originRef(f.editorBranch)} at ${short(editor.originSha)}` }
122
113
  : { effect: 'noop', reason: 'binary built from ' + originRef(f.editorBranch) + ' tip' };
123
114
 
124
- // (3) Reset editor — discards uncommitted + session, advances source, wipes
125
- // .codeyam. No-op ONLY when clean at origin tip with no session.
115
+ // Reset editor (editor-state reset) — discards uncommitted + session, advances
116
+ // source, wipes .codeyam. No-op ONLY when clean at origin tip with no session.
117
+ // INTERNAL-ONLY: the standalone "Reset editor" button was removed; this
118
+ // predicate survives purely as an input to the "Reset all + rebuild" composite
119
+ // below (which still runs resetEditorStateScript), so it is NOT returned/keyed.
126
120
  const resetEditor = editor == null
127
121
  ? { effect: 'unknown', reason: 'editor state not checked' }
128
122
  : (brokerLive || hasSession || editor.dirty || editor.behind > 0 || codeyamDirty)
129
123
  ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: editor.dirty, behind: editor.behind > 0, codeyam: codeyamDirty }) }
130
124
  : { effect: 'noop', reason: 'already clean at origin tip, no session' };
131
125
 
132
- // (3b) Reset client — hard-clean /workspace + .codeyam, drop the session.
133
- // No source-advance, so `behind` is NOT part of this predicate.
126
+ // (2) Reset client — hard-clean /workspace + .codeyam, drop the session, now
127
+ // ADVANCING to origin tip. Since the reset advances source, a VM behind origin
128
+ // would be changed by a re-run, so `behind` IS part of this predicate.
134
129
  const resetClient = client == null
135
130
  ? { effect: 'unknown', reason: 'client state not checked' }
136
- : (brokerLive || client.dirty || codeyamDirty || hasSession)
137
- ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: false, codeyam: codeyamDirty }) }
138
- : { effect: 'noop', reason: 'already clean, no session' };
131
+ : (brokerLive || client.dirty || client.behind > 0 || codeyamDirty || hasSession)
132
+ ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession, dirty: client.dirty, behind: client.behind > 0, codeyam: codeyamDirty }) }
133
+ : { effect: 'noop', reason: 'already clean at origin tip, no session' };
139
134
 
140
- // (4b) Update & Reset Everything — composite. Changes iff ANY component would
141
- // change: rebuild-binary, reset-editor-source, or the client being
142
- // behind/dirty. Reason summarizes which parts.
135
+ // (2b) End session — kills the live agent + clears session state. The ONLY
136
+ // thing it touches is the running session, so it changes the VM iff a live
137
+ // agent or a registered session exists; otherwise it's a clean no-op. Unlike
138
+ // the resets it ignores dirty/behind/.codeyam — it neither advances source nor
139
+ // wipes state.
140
+ const endSession = (brokerLive || hasSession)
141
+ ? { effect: 'changes', reason: resetReason({ agent: brokerLive, session: hasSession }) }
142
+ : { effect: 'noop', reason: 'no active session' };
143
+
144
+ // (4) Reset all + rebuild — composite. Changes iff ANY component would
145
+ // change: rebuild-binary, the editor-state reset (internal resetEditor
146
+ // predicate above), or the client being behind/dirty. Reason summarizes parts.
143
147
  const everything = (() => {
144
148
  if (editor == null || client == null) {
145
149
  return { effect: 'unknown', reason: 'state not checked' };
@@ -157,23 +161,20 @@ function computeButtonEffects(facts) {
157
161
  return { effect: 'noop', reason: 'everything already at origin tip, no session' };
158
162
  })();
159
163
 
160
- // (run-setup) Honest soft signal — there is no cheap upstream proof that
161
- // `npm run setup` would change nothing (DB/migration state isn't
162
- // introspected). `recommended` when a reset/update landed after the last
163
- // successful setup; otherwise `unknown`. Never a confident green no-op.
164
- const runSetup = f.setupRanSince
165
- ? { effect: 'unknown', reason: 'no setup-affecting change since last setup' }
166
- : { effect: 'recommended', reason: 'a reset/update landed since the last setup' };
167
-
168
164
  return {
169
165
  'update-client-source': updateClient,
170
- 'update-editor-source': updateEditor,
171
- 'reset-editor-source': resetEditor,
172
166
  'reset-client': resetClient,
167
+ 'end-session': endSession,
173
168
  'rebuild-binary': rebuild,
174
169
  'update-reset-everything': everything,
175
- 'run-setup': runSetup,
176
170
  };
177
171
  }
178
172
 
179
- module.exports = { computeButtonEffects, ACTION_IDS };
173
+ // Decide whether a Live Preview SSH forward needs to be re-wired (improve41)
174
+ function needsRewire(forwardAppPort, polledAppPort) {
175
+ if (typeof polledAppPort !== 'number' || isNaN(polledAppPort)) return false;
176
+ if (!forwardAppPort) return false;
177
+ return forwardAppPort !== polledAppPort;
178
+ }
179
+
180
+ module.exports = { computeButtonEffects, ACTION_IDS, needsRewire };
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // Pure operator-host placement resolution for the fleet dashboard
3
+ // (vm-dashboard-on-gce).
4
+ //
5
+ // The dashboard runs in two homes: the operator's laptop (legacy fallback) and a
6
+ // dedicated GCE "operator host" (the primary path). Three things differ by home —
7
+ // how gcloud authenticates, what address the dashboard binds, and how a branch
8
+ // pin is read from the env — and each used to be an inline ternary at the top of
9
+ // server.js's 200KB monolith, untestable. This module is the single home for
10
+ // those decisions, so server.js delegates the RULE and keeps only the I/O, the
11
+ // same split npm/fleet-branch.js (resolveFleetBranch) follows.
12
+ //
13
+ // Pure logic only — no I/O, no env reads beyond the explicit `env` argument a
14
+ // caller passes in — so it stays unit-testable in isolation (npm/fleet-host.test.js).
15
+
16
+ // Env var the systemd unit sets on the GCE operator host. Its presence (truthy)
17
+ // is what flips the dashboard from the laptop defaults to the on-GCE defaults.
18
+ const FLEET_ON_GCE_ENV = 'FLEET_ON_GCE';
19
+
20
+ /** True when the dashboard is running on the GCE operator host, read from the
21
+ * `FLEET_ON_GCE` env. Accepts the conventional truthy spellings `1` / `true` /
22
+ * `yes` (case-insensitive, trimmed); everything else — unset, empty, `0`,
23
+ * `false`, garbage — is false, so a laptop run (which never sets it) stays on
24
+ * the loopback + named-configuration defaults. */
25
+ function onGce(env = {}) {
26
+ return /^(1|true|yes)$/i.test(String(env[FLEET_ON_GCE_ENV] || '').trim());
27
+ }
28
+
29
+ /** Resolve the gcloud invocation prefix the dashboard's `${GCLOUD}` chokepoint
30
+ * uses. On the GCE operator host the attached SA authenticates from instance
31
+ * metadata, so a plain `gcloud` is correct (no key file, no named config). Off
32
+ * GCE (laptop) it pins the non-expiring SA configuration with
33
+ * `--configuration=<config>` so background loops survive OAuth-token expiry.
34
+ * Returns `'gcloud'` on GCE; throws if a non-GCE call omits the config name,
35
+ * since silently emitting a bare `gcloud` there would run on the operator's
36
+ * personal account — the exact regression the chokepoint exists to prevent. */
37
+ function gcloudCommand({ onGce: gce, config } = {}) {
38
+ if (gce) return 'gcloud';
39
+ const c = typeof config === 'string' ? config.trim() : '';
40
+ if (!c) {
41
+ throw new Error('gcloudCommand requires a config name when not on GCE');
42
+ }
43
+ return `gcloud --configuration=${c}`;
44
+ }
45
+
46
+ /** Resolve the address the dashboard binds. An explicit `bind` (the DASH_BIND
47
+ * override) always wins. Otherwise the host serves the LB's zonal NEG so it must
48
+ * bind the instance's internal NIC (`0.0.0.0`, firewalled to the LB + IAP
49
+ * ranges); a laptop run binds loopback only (`127.0.0.1`). A blank/whitespace
50
+ * override counts as absent so a set-but-empty env doesn't bind to an empty
51
+ * string. */
52
+ function dashboardBind({ onGce: gce, bind } = {}) {
53
+ const explicit = typeof bind === 'string' ? bind.trim() : '';
54
+ if (explicit) return explicit;
55
+ return gce ? '0.0.0.0' : '127.0.0.1';
56
+ }
57
+
58
+ /** Resolve the explicit branch pin from the env, or null when unpinned. The
59
+ * operator-host name is `FLEET_BRANCH`; `BRANCH` is the legacy laptop alias and
60
+ * is honored as a fallback. A blank/whitespace value is "no pin" (so the
61
+ * dashboard tracks live git HEAD via resolveFleetBranch). Returns the trimmed
62
+ * pin or null. */
63
+ function branchPin(env = {}) {
64
+ const pin = String(env.FLEET_BRANCH || env.BRANCH || '').trim();
65
+ return pin === '' ? null : pin;
66
+ }
67
+
68
+ module.exports = {
69
+ FLEET_ON_GCE_ENV,
70
+ onGce,
71
+ gcloudCommand,
72
+ dashboardBind,
73
+ branchPin,
74
+ };
@@ -2,7 +2,7 @@
2
2
 
3
3
  // npm/fleet-job-badge.js — pure verdict for the fleet dashboard's failed-job
4
4
  // badge reconciliation (improve39). A VM's last terminal-error heavy job
5
- // (add/error, reset-all error, reset-editor error, …) used to stick on the
5
+ // (add/error, reset-all error, reset-client error, …) used to stick on the
6
6
  // dashboard card indefinitely — even after the VM was recovered and is healthy
7
7
  // again — because the card renders the last job's error with no check against
8
8
  // the VM's live state, so a recovered VM keeps screaming a red error and
@@ -36,4 +36,48 @@ function shouldReconcileFailedBadge({ job, reachable, drifted, offBranch, degrad
36
36
  return true;
37
37
  }
38
38
 
39
- module.exports = { shouldReconcileFailedBadge };
39
+ // Given a reconcile verdict (from shouldReconcileFailedBadge) and whether the VM
40
+ // is now idle, decide whether the recovered badge should be FULLY CLEARED — the
41
+ // job entry dropped from state.jobs so the card goes clean — rather than merely
42
+ // muted as a "recovered (was: …)" note (improve41).
43
+ //
44
+ // badgeReconciled the shouldReconcileFailedBadge verdict for this VM
45
+ // idle the VM answered this poll, is not running a build session,
46
+ // and its broker is not actively serving an agent
47
+ //
48
+ // A recovered VM that is also idle has demonstrably finished recovering, so the
49
+ // stale error is dropped entirely. A recovered-but-not-yet-idle VM (mid-build,
50
+ // or a poll hasn't confirmed idle) keeps the muted note so the prior log stays
51
+ // visible during that transient window. Never clears a badge that wasn't first
52
+ // judged recovered — an unreachable / drifted / off-branch error stays surfaced.
53
+ function shouldClearReconciledBadge({ badgeReconciled, idle } = {}) {
54
+ return !!badgeReconciled && !!idle;
55
+ }
56
+
57
+ // Pure guard for the operator "Clear badge" action (improve41). Decides whether
58
+ // VM-n's persisted job entry may be dropped after an out-of-band recovery,
59
+ // independent of the server's state / fs I/O so the cases are unit-tested
60
+ // without a live dashboard. The shell (server.js) supplies the live signals and
61
+ // turns the verdict into the {ok,msg} response + the actual delete + snapshot.
62
+ //
63
+ // rostered the VM number is in the live roster
64
+ // job the carried job record ({ state, ... }) or null/undefined
65
+ //
66
+ // Only a TERMINAL job (error/done) on a rostered VM may be cleared, OR a terminal
67
+ // failed add (add/error) on a non-rostered VM (which deletes the card + add-N sidecar):
68
+ // any other job state / non-rostered combination is not clearable.
69
+ // Returns { ok, reason, cleanup } — reason is a stable code the shell maps to an
70
+ // operator message, and cleanup is the cleanup action to perform ('add-failed' vs 'badge-only').
71
+ function canClearBadge({ rostered, job } = {}) {
72
+ if (!rostered) {
73
+ if (job && job.action === 'add' && job.state === 'error') {
74
+ return { ok: true, reason: null, cleanup: 'add-failed' };
75
+ }
76
+ return { ok: false, reason: 'not-rostered' };
77
+ }
78
+ if (!job) return { ok: false, reason: 'no-badge' };
79
+ if (job.state !== 'error' && job.state !== 'done') return { ok: false, reason: 'job-active' };
80
+ return { ok: true, reason: null, cleanup: 'badge-only' };
81
+ }
82
+
83
+ module.exports = { shouldReconcileFailedBadge, shouldClearReconciledBadge, canClearBadge };