@codeyam-editor/codeyam-editor 0.1.4 → 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 +1415 -73
  3. package/npm/container.js +3 -0
  4. package/npm/disk-pressure.js +78 -0
  5. package/npm/editor-dev.js +96 -15
  6. package/npm/editor.js +85 -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 +51 -2
  10. package/npm/fleet-cleanup.js +118 -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 +1 -1
  15. package/npm/fleet-launchd.js +171 -0
  16. package/npm/fleet-live-status.js +123 -0
  17. package/npm/fleet-plans.js +135 -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 +43 -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 +204 -3
  35. package/npm/scenario-check.js +603 -32
  36. package/npm/scenario-handlers.js +78 -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 +329 -50
  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-BuK0lizt.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-tMMdarZD.js +0 -187
  61. package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
  62. package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
  63. package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
  64. package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
  65. package/ui/dist/assets/index-BGqairFV.css +0 -1
  66. package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
@@ -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,14 +207,33 @@ 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';
@@ -297,11 +331,87 @@ function orphanedAddsToReconcile({ jobs, sidecarKeys } = {}) {
297
331
  // posture). "Busy" (a live session or an in-flight add to this number) also
298
332
  // holds the entry for an operator rather than auto-removing — the shouldn't-
299
333
  // happen-but-fail-safe case where a vanished instance still reports a session.
300
- 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 } = {}) {
301
372
  if (exists === null || exists === undefined) return "leave";
302
- if (exists) return "keep";
303
- if (hasSession || hasPendingAdd) return "leave";
304
- 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";
305
415
  }
306
416
 
307
417
  module.exports = {
@@ -319,6 +429,7 @@ module.exports = {
319
429
  parseExitMarker,
320
430
  readExitMarker,
321
431
  clearExitMarker,
432
+ isSignalKillExit,
322
433
  reattachVerdict,
323
434
  reattachNextStep,
324
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
@@ -0,0 +1,171 @@
1
+ 'use strict';
2
+ // Pure helpers for running a VM `add` provision as its OWN transient launchd
3
+ // job (`com.codeyam.fleet-add-vm-<n>`) — improve46 Phase 3. The provision job
4
+ // is a SIBLING of `com.codeyam.fleet-dashboard` rather than a `setsid` child of
5
+ // it, so a `launchctl bootout` of the dashboard label (which tears down the
6
+ // whole dashboard launchd job tree regardless of session/process-group) has no
7
+ // authority over the provision. Phases 1 (signal-kill reclassification) and 2
8
+ // (the reinstall in-flight-add guard) still compose with this — Phase 3 reduces
9
+ // how often a bootout reaches the provision at all.
10
+ //
11
+ // All the string-building and parsing lives here so server.js can stay thin and
12
+ // these pieces are unit-testable WITHOUT launchctl (npm/fleet-launchd.test.js) —
13
+ // the same decomposition `npm/fleet-cleanup.js` uses for the reap brain.
14
+ //
15
+ // Stack assumption: launchd is macOS-only. server.js gates the whole own-job
16
+ // path on platform + a `launchctl` probe and falls back to the existing `setsid`
17
+ // detached spawn on cloud containers / CI / Linux, where these helpers are never
18
+ // reached. They are pure either way.
19
+
20
+ // The launchd label for VM-n's transient add-provision job. ONE label per VM
21
+ // number so the re-add collision guard and the completion teardown each address
22
+ // exactly one job. Mirrors the `com.codeyam.fleet-*` label family used by the
23
+ // dashboard's other launchd agents (see scripts/operator/launchd-reinstall.sh).
24
+ function addVmLabel(n) {
25
+ return `com.codeyam.fleet-add-vm-${n}`;
26
+ }
27
+
28
+ // The file the transient job's stdout/stderr are redirected to (the plist's
29
+ // Standard{Out,Error}Path). The dashboard tails this for the in-progress card's
30
+ // live log when the launchd path is taken — the equivalent of the `setsid`
31
+ // path's stdout pipe, which a separate launchd job can't share. Kept DISTINCT
32
+ // from `/tmp/dash-add-vm-<n>.log` (the addVm script's own `cloud:up` redirect)
33
+ // so the provision shell's progress echoes ("provisioning…", "VM-n provisioned")
34
+ // aren't interleaved with the cloud:up build output.
35
+ function addVmJobLogPath(n) {
36
+ return `/tmp/dash-add-vm-${n}.provision.log`;
37
+ }
38
+
39
+ // XML-escape a string for safe inclusion in a plist `<string>` body. Plists are
40
+ // XML, so the five predefined entities must be escaped or a path/env value with
41
+ // `&`/`<`/`>`/quotes would corrupt the plist. Pure.
42
+ function xmlEscape(s) {
43
+ return String(s == null ? '' : s)
44
+ .replace(/&/g, '&amp;')
45
+ .replace(/</g, '&lt;')
46
+ .replace(/>/g, '&gt;')
47
+ .replace(/"/g, '&quot;')
48
+ .replace(/'/g, '&apos;');
49
+ }
50
+
51
+ // Build a transient launchd plist (XML text) that runs `programArguments` ONCE
52
+ // under `label`. RunAtLoad=true so `launchctl bootstrap` fires it immediately;
53
+ // KeepAlive=false so launchd NEVER relaunches the provision after it exits (a
54
+ // one-shot job, not a daemon). stdout/stderr go to `logPath` so the dashboard
55
+ // can tail the live card. `environment` (the bootstrap form env) and
56
+ // `workingDirectory` are optional. Pure — caller writes the returned text to a
57
+ // temp file and bootstraps it.
58
+ function buildAddVmPlist({ label, programArguments, logPath, workingDirectory, environment } = {}) {
59
+ const args = (Array.isArray(programArguments) ? programArguments : [])
60
+ .map((a) => ` <string>${xmlEscape(a)}</string>`)
61
+ .join('\n');
62
+ const lines = [
63
+ '<?xml version="1.0" encoding="UTF-8"?>',
64
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
65
+ '<plist version="1.0">',
66
+ '<dict>',
67
+ ' <key>Label</key>',
68
+ ` <string>${xmlEscape(label)}</string>`,
69
+ ' <key>ProgramArguments</key>',
70
+ ' <array>',
71
+ args,
72
+ ' </array>',
73
+ ' <key>RunAtLoad</key>',
74
+ ' <true/>',
75
+ ' <key>KeepAlive</key>',
76
+ ' <false/>',
77
+ ];
78
+ if (logPath) {
79
+ lines.push(' <key>StandardOutPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
80
+ lines.push(' <key>StandardErrorPath</key>', ` <string>${xmlEscape(logPath)}</string>`);
81
+ }
82
+ if (workingDirectory) {
83
+ lines.push(' <key>WorkingDirectory</key>', ` <string>${xmlEscape(workingDirectory)}</string>`);
84
+ }
85
+ const env = environment && typeof environment === 'object' ? environment : null;
86
+ if (env && Object.keys(env).length > 0) {
87
+ lines.push(' <key>EnvironmentVariables</key>', ' <dict>');
88
+ for (const [k, v] of Object.entries(env)) {
89
+ lines.push(` <key>${xmlEscape(k)}</key>`, ` <string>${xmlEscape(v)}</string>`);
90
+ }
91
+ lines.push(' </dict>');
92
+ }
93
+ lines.push('</dict>', '</plist>', '');
94
+ return lines.join('\n');
95
+ }
96
+
97
+ // Parse the integer pid from `launchctl print gui/$UID/<label>` output. While
98
+ // the job's process is alive the block contains a `pid = N` line; this resolves
99
+ // it so the dashboard records the REAL provision pid into the sidecar `meta.pid`
100
+ // (the field `reattachVerdict`'s `kill -0` re-attach reads) rather than the
101
+ // launchctl invocation's own pid. Returns the integer pid, or null when there's
102
+ // no live process line (job not started yet, already exited, or junk). `pid = 0`
103
+ // is treated as no-pid. Pure: takes the text so it's testable without launchctl.
104
+ function parseLaunchdPrintPid(text) {
105
+ const m = String(text == null ? '' : text).match(/^\s*pid\s*=\s*(\d+)\b/m);
106
+ if (!m) return null;
107
+ const pid = Number(m[1]);
108
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
109
+ }
110
+
111
+ // Filter a captured transcript down to the live-card tail: drop the noisy
112
+ // gcloud/IAP/NumPy lines and keep the last `maxLines`. Pure — the same shaping
113
+ // startJob's `tailLog` applies to the setsid stdout pipe, extracted so the
114
+ // launchd path's logfile tail and the setsid path share ONE tested filter
115
+ // instead of duplicating the regex + slice.
116
+ function filterTailLog(text, maxLines = 14) {
117
+ return String(text == null ? '' : text).split('\n')
118
+ .filter((l) => !/WARNING|NumPy|google\.com\/iap/.test(l))
119
+ .join('\n').trim().split('\n').slice(-maxLines).join('\n');
120
+ }
121
+
122
+ // True when a `spawnSync` result represents a successful launchctl invocation:
123
+ // a defined result with a zero exit status and no spawn error (ENOENT etc.).
124
+ // Pure — lets the bootstrap/`load -w` success checks be one tested predicate.
125
+ function launchctlSpawnSucceeded(result) {
126
+ return !!result && result.status === 0 && !result.error;
127
+ }
128
+
129
+ // Decide what the launchd add-completion watcher should do on one tick, from the
130
+ // durable signals it gathers: the exit marker's code (or null when absent),
131
+ // whether the launchd process is still alive, and how many consecutive ticks it
132
+ // has been gone. Pure state machine — the I/O (reading the marker, `kill -0`,
133
+ // tailing the log) stays in the server.js watcher; this owns the decision so it
134
+ // is exhaustively testable without launchd.
135
+ // - marker present → done, that exit code (the normal path)
136
+ // - process gone for `graceTicks` → done, exit 1 (EXIT trap never wrote a
137
+ // marker — resolve the card as a failure
138
+ // rather than tailing forever)
139
+ // - process gone, under grace → keep waiting, bump goneTicks
140
+ // - process alive → keep waiting, reset goneTicks
141
+ function launchdWatchVerdict({ markerExitCode, pidAlive, goneTicks = 0, graceTicks = 3 } = {}) {
142
+ if (markerExitCode != null) return { done: true, exitCode: markerExitCode, goneTicks: 0 };
143
+ if (!pidAlive) {
144
+ const next = goneTicks + 1;
145
+ if (next >= graceTicks) return { done: true, exitCode: 1, goneTicks: next };
146
+ return { done: false, goneTicks: next };
147
+ }
148
+ return { done: false, goneTicks: 0 };
149
+ }
150
+
151
+ // Decide whether the own-launchd-job path should be taken for a spawn. Pure
152
+ // gate: it's taken only for a `detached` add (the sole caller that passes
153
+ // `detached`) AND on a host where `launchctl` is actually present. Everything
154
+ // else — a non-detached job, or a host with no launchctl (cloud container / CI /
155
+ // Linux) — falls back to the existing `setsid` detached spawn. Centralised here
156
+ // so the gate is one tested predicate, not an inline `&&` at the spawn site.
157
+ function shouldUseLaunchd({ detached, hasLaunchctl } = {}) {
158
+ return !!detached && !!hasLaunchctl;
159
+ }
160
+
161
+ module.exports = {
162
+ addVmLabel,
163
+ addVmJobLogPath,
164
+ xmlEscape,
165
+ buildAddVmPlist,
166
+ parseLaunchdPrintPid,
167
+ filterTailLog,
168
+ launchctlSpawnSucceeded,
169
+ launchdWatchVerdict,
170
+ shouldUseLaunchd,
171
+ };